From a4a7235346b3a760aabc4544bd7ada4b5a494bfb Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 May 2026 20:51:12 +0800 Subject: [PATCH] feat(enterprise): scope knowledge stores --- backend/package.json | 2 +- backend/src/agentOpenAI/openAiRuntime.ts | 14 +- .../__tests__/analysisPatternMemory.test.ts | 82 ++++- backend/src/agentv3/analysisPatternMemory.ts | 96 ++++- backend/src/agentv3/claudeMcpServer.ts | 65 +++- backend/src/agentv3/claudeRuntime.ts | 32 +- backend/src/agentv3/projectMemory.ts | 133 ++++++- backend/src/agentv3/types.ts | 4 + backend/src/routes/baselineRoutes.ts | 20 +- backend/src/routes/caseRoutes.ts | 38 +- backend/src/routes/ciGateRoutes.ts | 20 +- backend/src/routes/memoryRoutes.ts | 15 +- backend/src/routes/ragAdminRoutes.ts | 16 +- .../enterpriseKnowledgeScope.test.ts | 244 ++++++++++++ backend/src/services/baselineStore.ts | 54 ++- backend/src/services/caseGraph.ts | 86 ++++- backend/src/services/caseLibrary.ts | 116 +++++- backend/src/services/enterpriseRepository.ts | 4 +- backend/src/services/ragStore.ts | 70 +++- backend/src/services/scopedKnowledgeStore.ts | 347 ++++++++++++++++++ .../enterprise-multi-tenant/README.md | 2 +- 21 files changed, 1348 insertions(+), 112 deletions(-) create mode 100644 backend/src/services/__tests__/enterpriseKnowledgeScope.test.ts create mode 100644 backend/src/services/scopedKnowledgeStore.ts diff --git a/backend/package.json b/backend/package.json index 77749354..2252a7d3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,7 @@ "prepack": "npm run build", "typecheck": "tsc --noEmit", "test": "jest", - "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/enterpriseProviderStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts", + "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/agentv3/__tests__/analysisPatternMemory.test.ts src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts src/services/__tests__/enterpriseKnowledgeScope.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/enterpriseProviderStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest --testPathPatterns=src/tests", diff --git a/backend/src/agentOpenAI/openAiRuntime.ts b/backend/src/agentOpenAI/openAiRuntime.ts index c76c2ae0..cfb8fcfd 100644 --- a/backend/src/agentOpenAI/openAiRuntime.ts +++ b/backend/src/agentOpenAI/openAiRuntime.ts @@ -67,6 +67,7 @@ import { DEFAULT_OUTPUT_LANGUAGE, localize, type OutputLanguage } from '../agent import { loadOpenAIConfig, type OpenAIAgentConfig } from './openAiConfig'; import { createOpenAIToolsFromMcpDefinitions } from './openAiToolAdapter'; import type { ProviderScope } from '../services/providerManager'; +import type { KnowledgeScope } from '../services/scopedKnowledgeStore'; interface OpenAISessionEntry { history?: AgentInputItem[]; @@ -132,6 +133,16 @@ function providerScopeFromOptions(options: AnalysisOptions): ProviderScope | und }; } +function knowledgeScopeFromOptions(options: AnalysisOptions): KnowledgeScope | undefined { + if (!options.tenantId || !options.workspaceId) return undefined; + return { + tenantId: options.tenantId, + workspaceId: options.workspaceId, + userId: options.userId, + sourceRunId: options.runId, + }; +} + function summarizeToolOutput(value: unknown): string { const text = typeof value === 'string' ? value : JSON.stringify(value); if (!text) return ''; @@ -737,7 +748,7 @@ export class OpenAIRuntime extends EventEmitter implements IOrchestrator { let sqlErrors = this.sessionSqlErrors.get(sessionId); if (!sqlErrors) { - sqlErrors = loadLearnedSqlFixPairs(5); + sqlErrors = loadLearnedSqlFixPairs(5, knowledgeScopeFromOptions(options)); this.sessionSqlErrors.set(sessionId, sqlErrors); } @@ -770,6 +781,7 @@ export class OpenAIRuntime extends EventEmitter implements IOrchestrator { lightweight, skillNotesBudget, outputLanguage: config.outputLanguage, + knowledgeScope: knowledgeScopeFromOptions(options), }); const tools = createOpenAIToolsFromMcpDefinitions(toolDefinitions); diff --git a/backend/src/agentv3/__tests__/analysisPatternMemory.test.ts b/backend/src/agentv3/__tests__/analysisPatternMemory.test.ts index 08c744ee..c0e3e06f 100644 --- a/backend/src/agentv3/__tests__/analysisPatternMemory.test.ts +++ b/backend/src/agentv3/__tests__/analysisPatternMemory.test.ts @@ -16,13 +16,14 @@ * File I/O is mocked — no actual disk writes. */ -import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; // ── Mock fs before importing module ────────────────────────────────────── let mockPatterns: any[] = []; let mockNegativePatterns: any[] = []; let mockQuickPatterns: any[] = []; +const originalEnterprise = process.env.SMARTPERFETTO_ENTERPRISE; // Temporary storage for atomic write simulation (writeFile to .tmp, then rename) let tmpWriteBuffer: Map = new Map(); @@ -106,6 +107,14 @@ beforeEach(() => { setSupersedeStoreForTesting(null); }); +afterEach(() => { + if (originalEnterprise === undefined) { + delete process.env.SMARTPERFETTO_ENTERPRISE; + } else { + process.env.SMARTPERFETTO_ENTERPRISE = originalEnterprise; + } +}); + // ── Feature Extraction ─────────────────────────────────────────────────── describe('extractTraceFeatures', () => { @@ -529,6 +538,75 @@ describe('saveAnalysisPattern with extras', () => { }); }); +describe('enterprise scope isolation', () => { + const scopeA = {tenantId: 'tenant-a', workspaceId: 'workspace-a', userId: 'user-a'}; + const scopeB = {tenantId: 'tenant-b', workspaceId: 'workspace-b', userId: 'user-b'}; + const features = ['arch:STANDARD', 'scene:scrolling']; + + beforeEach(() => { + process.env.SMARTPERFETTO_ENTERPRISE = 'true'; + }); + + it('saves source scope provenance and filters positive matches before similarity ranking', async () => { + await saveAnalysisPattern( + features, + ['tenant-a insight'], + 'scrolling', + 'STANDARD', + 0.9, + {knowledgeScope: scopeA}, + ); + await saveAnalysisPattern( + features, + ['tenant-b insight'], + 'scrolling', + 'STANDARD', + 0.9, + {knowledgeScope: scopeB}, + ); + + expect(mockPatterns).toHaveLength(2); + expect(mockPatterns[0].provenance.sourceTenantId).toBe('tenant-a'); + expect(mockPatterns[1].provenance.sourceTenantId).toBe('tenant-b'); + expect(matchPatterns(features, scopeA).map(p => p.keyInsights[0])).toEqual([ + 'tenant-a insight', + ]); + expect(matchPatterns(features, scopeB).map(p => p.keyInsights[0])).toEqual([ + 'tenant-b insight', + ]); + }); + + it('filters negative and quick-path matches by the same enterprise scope', async () => { + await saveNegativePattern( + features, + [{type: 'sql_error', approach: 'bad sql', reason: 'tenant-a'}], + 'scrolling', + 'STANDARD', + {knowledgeScope: scopeA}, + ); + await saveNegativePattern( + features, + [{type: 'sql_error', approach: 'bad sql', reason: 'tenant-b'}], + 'scrolling', + 'STANDARD', + {knowledgeScope: scopeB}, + ); + await saveQuickPathPattern( + features, + ['tenant-a quick'], + 'scrolling', + 'STANDARD', + {knowledgeScope: scopeA}, + ); + + expect(matchNegativePatterns(features, scopeA)[0].failedApproaches[0].reason) + .toBe('tenant-a'); + expect(matchNegativePatterns(features, scopeB)[0].failedApproaches[0].reason) + .toBe('tenant-b'); + expect(matchQuickPatternsAsBackup(features, scopeB)).toHaveLength(0); + }); +}); + describe('matchPatterns status weighting', () => { it('drops entries with status=rejected', async () => { mockPatterns = [{ @@ -766,4 +844,4 @@ describe('sweepAutoConfirm', () => { expect(mockPatterns.find(p => p.id === 'conf').status).toBe('confirmed'); expect(mockPatterns.find(p => p.id === 'rej').status).toBe('rejected'); }); -}); \ No newline at end of file +}); diff --git a/backend/src/agentv3/analysisPatternMemory.ts b/backend/src/agentv3/analysisPatternMemory.ts index aa4e9410..988da039 100644 --- a/backend/src/agentv3/analysisPatternMemory.ts +++ b/backend/src/agentv3/analysisPatternMemory.ts @@ -35,6 +35,11 @@ import { injectionWeightForSupersede, type SupersedeStoreHandle, } from './selfImprove/supersedeStore'; +import { + enterpriseKnowledgeStoreEnabled, + type KnowledgeScope, + resolveKnowledgeScope, +} from '../services/scopedKnowledgeStore'; const PATTERNS_FILE = path.resolve(__dirname, '../../logs/analysis_patterns.json'); const NEGATIVE_PATTERNS_FILE = path.resolve(__dirname, '../../logs/analysis_negative_patterns.json'); @@ -238,6 +243,34 @@ export interface PatternSaveExtras { failureModeHash?: string; provenance?: PatternProvenance; bucketKey?: string; + /** Enterprise tenant/workspace scope for learned pattern isolation. */ + knowledgeScope?: KnowledgeScope; +} + +function withKnowledgeScopeProvenance( + provenance: PatternProvenance | undefined, + scope: KnowledgeScope | undefined, +): PatternProvenance | undefined { + if (!enterpriseKnowledgeStoreEnabled() && !scope) return provenance; + const resolved = resolveKnowledgeScope(scope); + return { + ...(provenance ?? {}), + sourceTenantId: resolved.tenantId, + sourceWorkspaceId: resolved.workspaceId, + sourceRunId: resolved.sourceRunId ?? provenance?.analysisRunId, + }; +} + +function patternMatchesKnowledgeScope( + pattern: {provenance?: PatternProvenance}, + scope: KnowledgeScope | undefined, +): boolean { + if (!enterpriseKnowledgeStoreEnabled()) return true; + const resolved = resolveKnowledgeScope(scope); + return ( + pattern.provenance?.sourceTenantId === resolved.tenantId && + pattern.provenance?.sourceWorkspaceId === resolved.workspaceId + ); } /** Load patterns from disk. */ @@ -392,9 +425,16 @@ export async function saveAnalysisPattern( const patterns = loadPatterns(); const now = Date.now(); + const provenance = withKnowledgeScopeProvenance( + extras.provenance, + extras.knowledgeScope, + ); // Deduplicate: check if a very similar pattern already exists (>70% similarity) - const existingIdx = patterns.findIndex(p => weightedJaccardSimilarity(p.traceFeatures, features) > 0.7); + const existingIdx = patterns.findIndex(p => + patternMatchesKnowledgeScope(p, extras.knowledgeScope) && + weightedJaccardSimilarity(p.traceFeatures, features) > 0.7, + ); if (existingIdx >= 0) { // Update existing pattern: merge insights, bump match count @@ -406,7 +446,7 @@ export async function saveAnalysisPattern( if (confidence !== undefined) existing.confidence = confidence; if (extras.failureModeHash) existing.failureModeHash = extras.failureModeHash; if (extras.bucketKey) existing.bucketKey = extras.bucketKey; - if (extras.provenance) existing.provenance = extras.provenance; + if (provenance) existing.provenance = provenance; // Re-saves don't downgrade status — a provisional pattern that has // already auto-confirmed must not slip back to provisional. } else { @@ -423,7 +463,7 @@ export async function saveAnalysisPattern( status: extras.status ?? 'provisional', failureModeHash: extras.failureModeHash, bucketKey: extras.bucketKey, - provenance: extras.provenance, + provenance, }); } @@ -451,9 +491,16 @@ export async function saveNegativePattern( if (features.length === 0 || failedApproaches.length === 0) return; const patterns = loadNegativePatterns(); + const provenance = withKnowledgeScopeProvenance( + extras.provenance, + extras.knowledgeScope, + ); // Deduplicate: merge into existing pattern if >70% similar - const existingIdx = patterns.findIndex(p => weightedJaccardSimilarity(p.traceFeatures, features) > 0.7); + const existingIdx = patterns.findIndex(p => + patternMatchesKnowledgeScope(p, extras.knowledgeScope) && + weightedJaccardSimilarity(p.traceFeatures, features) > 0.7, + ); const now = Date.now(); // Recurrence detection: a fresh negative on a hash that's currently being @@ -476,7 +523,7 @@ export async function saveNegativePattern( existing.createdAt = now; if (extras.failureModeHash) existing.failureModeHash = extras.failureModeHash; if (extras.bucketKey) existing.bucketKey = extras.bucketKey; - if (extras.provenance) existing.provenance = extras.provenance; + if (provenance) existing.provenance = provenance; } else { const id = `neg-${now}-${Math.random().toString(36).substring(2, 6)}`; patterns.push({ @@ -490,7 +537,7 @@ export async function saveNegativePattern( status: extras.status ?? 'provisional', failureModeHash: extras.failureModeHash, bucketKey: extras.bucketKey, - provenance: extras.provenance, + provenance, }); } @@ -508,7 +555,10 @@ export async function saveNegativePattern( * Find patterns similar to the current trace features. * Returns matched patterns sorted by effective score (similarity × decay). */ -export function matchPatterns(features: string[]): Array { +export function matchPatterns( + features: string[], + scope?: KnowledgeScope, +): Array { if (features.length === 0) return []; const patterns = loadPatterns(); @@ -516,6 +566,7 @@ export function matchPatterns(features: string[]): Array p.createdAt >= cutoff) + .filter(p => patternMatchesKnowledgeScope(p, scope)) .filter(p => getEffectiveStatus(p) !== 'rejected') .map(p => { const rawSimilarity = weightedJaccardSimilarity(p.traceFeatures, features); @@ -537,7 +588,10 @@ export function matchPatterns(features: string[]): Array { +export function matchNegativePatterns( + features: string[], + scope?: KnowledgeScope, +): Array { if (features.length === 0) return []; const patterns = loadNegativePatterns(); @@ -545,6 +599,7 @@ export function matchNegativePatterns(features: string[]): Array p.createdAt >= cutoff) + .filter(p => patternMatchesKnowledgeScope(p, scope)) .filter(p => getEffectiveStatus(p) !== 'rejected') .map(p => { const frequencyGain = 1 + Math.log2(1 + p.matchCount) * 0.1; @@ -626,6 +681,10 @@ export async function saveQuickPathPattern( const patterns = loadQuickPatterns(); const now = Date.now(); const id = `qp-${now}-${Math.random().toString(36).substring(2, 6)}`; + const provenance = withKnowledgeScopeProvenance( + extras.provenance, + extras.knowledgeScope, + ); patterns.push({ id, traceFeatures: features, @@ -638,7 +697,7 @@ export async function saveQuickPathPattern( status: extras.status ?? 'provisional', failureModeHash: extras.failureModeHash, bucketKey: extras.bucketKey, - provenance: extras.provenance, + provenance, }); const cutoff = now - QUICK_PATTERN_TTL_MS; @@ -657,12 +716,14 @@ export async function saveQuickPathPattern( */ export function matchQuickPatternsAsBackup( features: string[], + scope?: KnowledgeScope, ): Array { if (features.length === 0) return []; const patterns = loadQuickPatterns(); const cutoff = Date.now() - QUICK_PATTERN_TTL_MS; return patterns .filter(p => p.createdAt >= cutoff) + .filter(p => patternMatchesKnowledgeScope(p, scope)) .filter(p => getEffectiveStatus(p) !== 'rejected') .map(p => { const rawSimilarity = weightedJaccardSimilarity(p.traceFeatures, features); @@ -693,10 +754,12 @@ export async function promoteQuickPatternIfMatching(input: { sceneType: string; architectureType?: string; verifierPassed: boolean; + knowledgeScope?: KnowledgeScope; }): Promise { if (!input.verifierPassed) return false; const candidates = loadQuickPatterns(); const winner = candidates + .filter(p => patternMatchesKnowledgeScope(p, input.knowledgeScope)) .filter(p => getEffectiveStatus(p) !== 'rejected' && getEffectiveStatus(p) !== 'disputed') .filter(p => p.sceneType === input.sceneType && p.architectureType === input.architectureType) .map(p => ({ @@ -728,6 +791,7 @@ export async function promoteQuickPatternIfMatching(input: { failureModeHash: winner.pattern.failureModeHash, bucketKey: winner.pattern.bucketKey, provenance: winner.pattern.provenance, + knowledgeScope: input.knowledgeScope, }, ); return true; @@ -842,8 +906,11 @@ export async function sweepAutoConfirm(now: number = Date.now()): Promise * Build a system prompt section from matched patterns. * Provides cross-session context to Claude. */ -export function buildPatternContextSection(features: string[]): string | undefined { - const matches = matchPatterns(features); +export function buildPatternContextSection( + features: string[], + scope?: KnowledgeScope, +): string | undefined { + const matches = matchPatterns(features, scope); if (matches.length === 0) return undefined; const lines = matches.map((m, i) => { @@ -865,8 +932,11 @@ ${lines.join('\n\n')} * Build a system prompt section from matched negative patterns. * Warns Claude about strategies that previously FAILED for similar traces. */ -export function buildNegativePatternSection(features: string[]): string | undefined { - const matches = matchNegativePatterns(features); +export function buildNegativePatternSection( + features: string[], + scope?: KnowledgeScope, +): string | undefined { + const matches = matchNegativePatterns(features, scope); if (matches.length === 0) return undefined; const lines: string[] = []; diff --git a/backend/src/agentv3/claudeMcpServer.ts b/backend/src/agentv3/claudeMcpServer.ts index c463f342..ffdf5267 100644 --- a/backend/src/agentv3/claudeMcpServer.ts +++ b/backend/src/agentv3/claudeMcpServer.ts @@ -43,6 +43,11 @@ import { } from '../services/baselineDiffer'; import {ProjectMemory} from './projectMemory'; import {CaseLibrary} from '../services/caseLibrary'; +import { + enterpriseKnowledgeStoreEnabled, + type KnowledgeScope, + resolveKnowledgeScope, +} from '../services/scopedKnowledgeStore'; import { McpToolRegistry, MCP_NAME_PREFIX as REGISTRY_MCP_NAME_PREFIX, @@ -173,9 +178,25 @@ const REASONING_NUDGE_ZH = '\n\n[REFLECT] 在执行下一步之前:这个数 const REASONING_NUDGE_EN = '\n\n[REFLECT] Before the next action: what is the key finding from this data? Does it support or refute your hypothesis? If there is an important inference, record it with submit_hypothesis or write_analysis_note.'; export const MIN_PHASE_SUMMARY_CHARS = 15; -export function loadLearnedSqlFixPairs(maxPairs = 10): SqlErrorFixPair[] { +function sqlErrorLogFile(scope?: KnowledgeScope): string { + if (!enterpriseKnowledgeStoreEnabled()) { + return path.join(SQL_ERROR_LOG_DIR, 'error_fix_pairs.json'); + } + const resolved = resolveKnowledgeScope(scope); + return path.join( + SQL_ERROR_LOG_DIR, + resolved.tenantId, + resolved.workspaceId, + 'error_fix_pairs.json', + ); +} + +export function loadLearnedSqlFixPairs( + maxPairs = 10, + scope?: KnowledgeScope, +): SqlErrorFixPair[] { try { - const logFile = path.join(SQL_ERROR_LOG_DIR, 'error_fix_pairs.json'); + const logFile = sqlErrorLogFile(scope); if (!fs.existsSync(logFile)) return []; const data = fs.readFileSync(logFile, 'utf-8'); const pairs: SqlErrorFixPair[] = JSON.parse(data); @@ -189,9 +210,12 @@ export function loadLearnedSqlFixPairs(maxPairs = 10): SqlErrorFixPair[] { } } -async function logSqlErrorFixPair(pair: SqlErrorFixPair): Promise { +async function logSqlErrorFixPair( + pair: SqlErrorFixPair, + scope?: KnowledgeScope, +): Promise { try { - const logFile = path.join(SQL_ERROR_LOG_DIR, 'error_fix_pairs.json'); + const logFile = sqlErrorLogFile(scope); let pairs: SqlErrorFixPair[] = []; try { const data = await fs.promises.readFile(logFile, 'utf-8'); @@ -208,7 +232,7 @@ async function logSqlErrorFixPair(pair: SqlErrorFixPair): Promise { } // Keep last 200 pairs if (pairs.length > 200) pairs = pairs.slice(-200); - await fs.promises.mkdir(SQL_ERROR_LOG_DIR, { recursive: true }); + await fs.promises.mkdir(path.dirname(logFile), { recursive: true }); // Atomic write: write to tmp file, then rename const tmpFile = logFile + '.tmp'; await fs.promises.writeFile(tmpFile, JSON.stringify(pairs)); @@ -347,6 +371,8 @@ export interface ClaudeMcpServerOptions { skillNotesBudget?: import('./selfImprove/skillNotesInjector').SkillNotesBudget; /** User-facing output language for backend-emitted progress and hints. */ outputLanguage?: OutputLanguage; + /** Enterprise tenant/workspace scope for knowledge, memory, case, and baseline tools. */ + knowledgeScope?: KnowledgeScope; } /** @@ -361,6 +387,7 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { const watchdogRef = options.watchdogWarning; const skillNotesBudget = options.skillNotesBudget; const outputLanguage = options.outputLanguage ?? DEFAULT_OUTPUT_LANGUAGE; + const knowledgeScope = options.knowledgeScope; /** Normalize skill params: ensure process_name ↔ package are both set. */ function normalizeSkillParams(params: Record | undefined, defaultPackage?: string): Record { @@ -534,7 +561,10 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { return jaccard > 0.3; // At least 30% token overlap }); if (matchingError) { - await logSqlErrorFixPair({ ...matchingError, fixedSql: sql }); + await logSqlErrorFixPair( + { ...matchingError, fixedSql: sql }, + knowledgeScope, + ); const idx = recentSqlErrors.indexOf(matchingError); if (idx >= 0) recentSqlErrors.splice(idx, 1); } @@ -673,7 +703,7 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { recentSqlErrors.push(errorPair); if (recentSqlErrors.length > 10) recentSqlErrors.shift(); // Persist to disk (fire-and-forget) for cross-session learning - logSqlErrorFixPair(errorPair).catch(() => {}); + logSqlErrorFixPair(errorPair, knowledgeScope).catch(() => {}); } if (emitUpdate && result.displayResults?.length) { @@ -1217,6 +1247,7 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { const result = store.search(query, { topK: top_k ?? 5, kinds: ['androidperformance.com'], + scope: knowledgeScope, }); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], @@ -1261,7 +1292,7 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { }; } } - const baseline = store.getBaseline(id); + const baseline = store.getBaseline(id, knowledgeScope); if (!baseline) { return { content: [{ @@ -1299,8 +1330,8 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { }, async ({ base_baseline_id, candidate_baseline_id, rules, gate_id }) => { const store = getBaselineStore(); - const base = store.getBaseline(base_baseline_id); - const candidate = store.getBaseline(candidate_baseline_id); + const base = store.getBaseline(base_baseline_id, knowledgeScope); + const candidate = store.getBaseline(candidate_baseline_id, knowledgeScope); if (!base) { return { content: [{ @@ -1366,6 +1397,7 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { const result = store.search(query, { topK: top_k ?? 5, kinds: ['aosp'], + scope: knowledgeScope, }); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], @@ -1391,6 +1423,7 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { const result = store.search(query, { topK: top_k ?? 5, kinds: ['oem_sdk'], + scope: knowledgeScope, }); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], @@ -1425,7 +1458,7 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { projectKey: project_key, scope, topK: top_k ?? 5, - }); + }, knowledgeScope); return { content: [{ type: 'text' as const, @@ -1466,10 +1499,10 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { // through this tool — those are operator-side concerns. const allCases = include_unpublished ? [ - ...library.listCases({status: 'published'}), - ...library.listCases({status: 'reviewed'}), + ...library.listCases({status: 'published'}, knowledgeScope), + ...library.listCases({status: 'reviewed'}, knowledgeScope), ] - : library.listCases({status: 'published'}); + : library.listCases({status: 'published'}, knowledgeScope); const candidates: Array<{caseScore: number; case: typeof allCases[number]}> = []; for (const c of allCases) { @@ -2196,8 +2229,8 @@ export function createClaudeMcpServer(options: ClaudeMcpServerOptions) { } } - const positiveMatches = matchPatterns(features); - const negativeMatches = matchNegativePatterns(features); + const positiveMatches = matchPatterns(features, knowledgeScope); + const negativeMatches = matchNegativePatterns(features, knowledgeScope); if (positiveMatches.length === 0 && negativeMatches.length === 0) { return { diff --git a/backend/src/agentv3/claudeRuntime.ts b/backend/src/agentv3/claudeRuntime.ts index 1103e7f7..8d428bcd 100644 --- a/backend/src/agentv3/claudeRuntime.ts +++ b/backend/src/agentv3/claudeRuntime.ts @@ -91,6 +91,7 @@ import { type ClaudeSessionMapRuntimeEntry, } from '../services/runtimeSnapshotStore'; import type { ProviderScope } from '../services/providerManager'; +import type { KnowledgeScope } from '../services/scopedKnowledgeStore'; const SESSION_MAP_FILE = path.resolve(__dirname, '../../logs/claude_session_map.json'); /** Max age for session map entries before pruning (24 hours). */ @@ -156,6 +157,16 @@ function providerScopeFromOptions(options: AnalysisOptions): ProviderScope | und }; } +function knowledgeScopeFromOptions(options: AnalysisOptions): KnowledgeScope | undefined { + if (!options.tenantId || !options.workspaceId) return undefined; + return { + tenantId: options.tenantId, + workspaceId: options.workspaceId, + userId: options.userId, + sourceRunId: options.runId, + }; +} + /** * Debounce timer for session map persistence — avoids blocking event loop on every SDK message. * P2-1: Use a Map keyed by the Map reference to support multiple ClaudeRuntime instances. @@ -1448,6 +1459,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { sessionId, turnIndex: ctx.previousTurns.length, }, + knowledgeScope: knowledgeScopeFromOptions(options), }; saveAnalysisPattern(fullFeatures, insights, sceneType, ctx.architecture?.type, turnConfidence, patternExtras) .catch(err => console.warn('[ClaudeRuntime] Pattern save failed:', (err as Error).message)); @@ -1460,6 +1472,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { sceneType, architectureType: ctx.architecture?.type, verifierPassed: true, + knowledgeScope: knowledgeScopeFromOptions(options), }).catch(err => console.warn('[ClaudeRuntime] Quick→full promote failed:', (err as Error).message)); } @@ -1478,7 +1491,9 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { // P1: Save negative patterns to long-term memory (fire-and-forget) if (failedApproaches.length > 0 && fullFeatures.length > 0) { - saveNegativePattern(fullFeatures, failedApproaches, sceneType, ctx.architecture?.type) + saveNegativePattern(fullFeatures, failedApproaches, sceneType, ctx.architecture?.type, { + knowledgeScope: knowledgeScopeFromOptions(options), + }) .catch(err => console.warn('[ClaudeRuntime] Negative pattern save failed:', (err as Error).message)); } @@ -1661,6 +1676,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { lightweight: true, skillNotesBudget: quickNotesBudget, outputLanguage: this.config.outputLanguage, + knowledgeScope: knowledgeScopeFromOptions(options), }); const systemPrompt = buildQuickSystemPrompt({ @@ -1882,6 +1898,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { saveQuickPathPattern(quickFeatures, insights, sceneType, cachedArch?.type, { status: 'provisional', provenance: { sessionId, turnIndex: previousTurns.length }, + knowledgeScope: knowledgeScopeFromOptions(options), }).catch(err => console.warn('[ClaudeRuntime] Quick pattern save failed:', (err as Error).message)); } @@ -2415,8 +2432,14 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { sceneType, packageName: effectivePackageName, }); - const patternContext = buildPatternContextSection(traceFeatures); - const negativePatternContext = buildNegativePatternSection(traceFeatures); + const patternContext = buildPatternContextSection( + traceFeatures, + knowledgeScopeFromOptions(options), + ); + const negativePatternContext = buildNegativePatternSection( + traceFeatures, + knowledgeScopeFromOptions(options), + ); // Phase 6: Session-scoped artifact store + analysis notes if (!this.artifactStores.has(sessionId)) { @@ -2465,7 +2488,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { // Seed new sessions with previously learned fix pairs from disk (cross-session learning) let sqlErrors = this.sessionSqlErrors.get(sessionId); if (!sqlErrors) { - sqlErrors = loadLearnedSqlFixPairs(5); + sqlErrors = loadLearnedSqlFixPairs(5, knowledgeScopeFromOptions(options)); this.sessionSqlErrors.set(sessionId, sqlErrors); } @@ -2499,6 +2522,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { comparisonContext, skillNotesBudget: fullNotesBudget, outputLanguage: this.config.outputLanguage, + knowledgeScope: knowledgeScopeFromOptions(options), }); // Phase 9: (removed — skillCatalog was populated but never used in prompt; diff --git a/backend/src/agentv3/projectMemory.ts b/backend/src/agentv3/projectMemory.ts index ade0700e..2735227f 100644 --- a/backend/src/agentv3/projectMemory.ts +++ b/backend/src/agentv3/projectMemory.ts @@ -47,6 +47,14 @@ import { type MemoryPromotionTrigger, type MemoryScope, } from '../types/sparkContracts'; +import { + enterpriseKnowledgeStoreEnabled, + type KnowledgeScope, + getScopedKnowledgeRecord, + listScopedKnowledgeRecords, + removeScopedKnowledgeRecord, + upsertScopedKnowledgeRecord, +} from '../services/scopedKnowledgeStore'; const VALID_PROMOTION_TRIGGERS: ReadonlySet = new Set([ 'user_feedback', @@ -69,6 +77,9 @@ interface StorageEnvelope { promotionAudit: PromotionAuditEntry[]; } +const KNOWLEDGE_KIND = 'project_memory'; +const MEMORY_ROW_SCOPE_PREFIX = 'memory:'; + export interface ListOptions { scope?: Exclude; projectKey?: string; @@ -138,22 +149,56 @@ export class ProjectMemory { * - scope is 'world' but promotionPolicy is missing * - promotionPolicy carries a trigger outside the canonical enum */ - saveProjectMemoryEntry(entry: ProjectMemoryEntry): void { + saveProjectMemoryEntry( + entry: ProjectMemoryEntry, + storageScope?: KnowledgeScope, + ): void { this.load(); this.assertSaveInvariants(entry); + if (enterpriseKnowledgeStoreEnabled()) { + upsertScopedKnowledgeRecord( + KNOWLEDGE_KIND, + entry.entryId, + memoryRowScope(entry.scope), + entry, + storageScope, + { + createdAt: entry.createdAt, + updatedAt: entry.lastSeenAt ?? entry.createdAt, + sourceRunId: storageScope?.sourceRunId ?? storageScope?.runId, + }, + ); + return; + } this.entries.set(entry.entryId, entry); this.persist(); } /** Get an entry by id. */ - getProjectMemoryEntry(entryId: string): ProjectMemoryEntry | undefined { + getProjectMemoryEntry( + entryId: string, + storageScope?: KnowledgeScope, + ): ProjectMemoryEntry | undefined { + if (enterpriseKnowledgeStoreEnabled()) { + return getScopedKnowledgeRecord( + KNOWLEDGE_KIND, + entryId, + storageScope, + )?.record; + } this.load(); return this.entries.get(entryId); } /** Remove an entry. Returns whether it was present. The promotion * audit log is NOT redacted — past promotions stay traceable. */ - removeProjectMemoryEntry(entryId: string): boolean { + removeProjectMemoryEntry( + entryId: string, + storageScope?: KnowledgeScope, + ): boolean { + if (enterpriseKnowledgeStoreEnabled()) { + return removeScopedKnowledgeRecord(KNOWLEDGE_KIND, entryId, storageScope); + } this.load(); const had = this.entries.delete(entryId); if (had) this.persist(); @@ -161,9 +206,21 @@ export class ProjectMemory { } /** Filtered list, deterministically ordered by entryId. */ - listProjectMemoryEntries(opts: ListOptions = {}): ProjectMemoryEntry[] { + listProjectMemoryEntries( + opts: ListOptions = {}, + storageScope?: KnowledgeScope, + ): ProjectMemoryEntry[] { this.load(); - let out = Array.from(this.entries.values()); + let out = enterpriseKnowledgeStoreEnabled() + ? listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + storageScope, + { + rowScope: opts.scope ? memoryRowScope(opts.scope) : undefined, + rowScopePrefix: opts.scope ? undefined : MEMORY_ROW_SCOPE_PREFIX, + }, + ).map(row => row.record) + : Array.from(this.entries.values()); if (opts.scope) out = out.filter(e => e.scope === opts.scope); if (opts.projectKey) out = out.filter(e => e.projectKey === opts.projectKey); @@ -180,13 +237,26 @@ export class ProjectMemory { * tags. Never writes — `lastSeenAt` and any other mutable counters * remain frozen even after thousands of recall calls. */ - recallProjectMemory(opts: RecallOptions = {}): RecallHit[] { + recallProjectMemory( + opts: RecallOptions = {}, + storageScope?: KnowledgeScope, + ): RecallHit[] { this.load(); const topK = opts.topK ?? 5; const wantedTags = opts.tags ? new Set(opts.tags) : null; const candidates: RecallHit[] = []; + const entries = enterpriseKnowledgeStoreEnabled() + ? listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + storageScope, + { + rowScope: opts.scope ? memoryRowScope(opts.scope) : undefined, + rowScopePrefix: opts.scope ? undefined : MEMORY_ROW_SCOPE_PREFIX, + }, + ).map(row => row.record) + : Array.from(this.entries.values()); - for (const entry of this.entries.values()) { + for (const entry of entries) { if (opts.scope && entry.scope !== opts.scope) continue; if (opts.projectKey && entry.projectKey !== opts.projectKey) continue; if (entry.unsupportedReason) continue; @@ -212,7 +282,11 @@ export class ProjectMemory { * any trigger outside the enum; appends a row to the promotion * audit log; sets `entry.promotionPolicy` to the supplied policy. */ - promoteEntry(entryId: string, policy: MemoryPromotionPolicy): void { + promoteEntry( + entryId: string, + policy: MemoryPromotionPolicy, + storageScope?: KnowledgeScope, + ): void { this.load(); if (!VALID_PROMOTION_TRIGGERS.has(policy.trigger)) { throw new Error( @@ -234,7 +308,13 @@ export class ProjectMemory { "Promotion with trigger='skill_eval_pass' requires an `evalCaseId` field", ); } - const entry = this.entries.get(entryId); + const entry = enterpriseKnowledgeStoreEnabled() + ? getScopedKnowledgeRecord( + KNOWLEDGE_KIND, + entryId, + storageScope, + )?.record + : this.entries.get(entryId); if (!entry) { throw new Error(`Cannot promote: entry '${entryId}' not found`); } @@ -249,6 +329,23 @@ export class ProjectMemory { promotionLevel: (entry.promotionLevel ?? 0) + 1, promotionPolicy: policy, }; + if (enterpriseKnowledgeStoreEnabled()) { + upsertScopedKnowledgeRecord( + KNOWLEDGE_KIND, + entryId, + memoryRowScope(promoted.scope), + promoted, + storageScope, + { + createdAt: promoted.createdAt, + updatedAt: Date.now(), + sourceRunId: storageScope?.sourceRunId ?? storageScope?.runId, + }, + ); + this.auditLog.push({entryId, policy, auditedAt: Date.now()}); + this.persist(); + return; + } this.entries.set(entryId, promoted); this.auditLog.push({entryId, policy, auditedAt: Date.now()}); this.persist(); @@ -261,10 +358,17 @@ export class ProjectMemory { } /** Count entries currently in storage by scope. */ - getStats(): Record<'project' | 'world', number> { + getStats(storageScope?: KnowledgeScope): Record<'project' | 'world', number> { this.load(); const out = {project: 0, world: 0}; - for (const entry of this.entries.values()) { + const entries = enterpriseKnowledgeStoreEnabled() + ? listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + storageScope, + {rowScopePrefix: MEMORY_ROW_SCOPE_PREFIX}, + ).map(row => row.record) + : Array.from(this.entries.values()); + for (const entry of entries) { if (entry.scope === 'project') out.project++; else if (entry.scope === 'world') out.world++; } @@ -307,3 +411,10 @@ export class ProjectMemory { fs.renameSync(tmp, this.storagePath); } } + +function memoryRowScope(scope: MemoryScope): string { + if (scope === 'session') { + throw new Error(`ProjectMemory does not store session-scope entries`); + } + return `${MEMORY_ROW_SCOPE_PREFIX}${scope}`; +} diff --git a/backend/src/agentv3/types.ts b/backend/src/agentv3/types.ts index ecda6144..9b110488 100644 --- a/backend/src/agentv3/types.ts +++ b/backend/src/agentv3/types.ts @@ -364,6 +364,10 @@ export interface PatternProvenance { analysisRunId?: string; sessionId?: string; turnIndex?: number; + /** Enterprise scope for learned memory isolation. */ + sourceTenantId?: string; + sourceWorkspaceId?: string; + sourceRunId?: string; /** sha256 of trace file content (NOT the upload UUID — see scene/traceHash.ts). */ traceContentHash?: string; } diff --git a/backend/src/routes/baselineRoutes.ts b/backend/src/routes/baselineRoutes.ts index a7705008..d199d7ff 100644 --- a/backend/src/routes/baselineRoutes.ts +++ b/backend/src/routes/baselineRoutes.ts @@ -26,8 +26,9 @@ import * as path from 'path'; import {Router, type Router as ExpressRouter} from 'express'; -import {authenticate} from '../middleware/auth'; +import {authenticate, requireRequestContext} from '../middleware/auth'; import {BaselineStore} from '../services/baselineStore'; +import {knowledgeScopeFromRequestContext} from '../services/scopedKnowledgeStore'; import type {BaselineRecord} from '../types/sparkContracts'; /** Default storage path. Lives under `backend/logs/` next to the other @@ -48,6 +49,7 @@ function getDefaultStore(): BaselineStore { export function createBaselineRoutes(store?: BaselineStore): ExpressRouter { const s = store ?? getDefaultStore(); const router = Router(); + router.use(authenticate); /** * POST /api/baselines @@ -56,7 +58,8 @@ export function createBaselineRoutes(store?: BaselineStore): ExpressRouter { * (sampleCount ≥ 3, redactionState='redacted' when key carries * identifiable info) surface as 400 with a descriptive `error`. */ - router.post('/', authenticate, (req, res) => { + router.post('/', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const record = req.body as BaselineRecord | undefined; if (!record || typeof record !== 'object') { return res @@ -70,7 +73,7 @@ export function createBaselineRoutes(store?: BaselineStore): ExpressRouter { }); } try { - s.addBaseline(record); + s.addBaseline(record, scope); return res.status(201).json({success: true, baseline: record}); } catch (err) { return res.status(400).json({ @@ -82,7 +85,8 @@ export function createBaselineRoutes(store?: BaselineStore): ExpressRouter { /** GET /api/baselines/:id */ router.get('/:id', (req, res) => { - const baseline = s.getBaseline(req.params.id); + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + const baseline = s.getBaseline(req.params.id, scope); if (!baseline) { return res .status(404) @@ -92,9 +96,10 @@ export function createBaselineRoutes(store?: BaselineStore): ExpressRouter { }); /** DELETE /api/baselines/:id */ - router.delete('/:id', authenticate, (req, res) => { + router.delete('/:id', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const id = req.params.id as string; - const removed = s.removeBaseline(id); + const removed = s.removeBaseline(id, scope); if (!removed) { return res .status(404) @@ -111,6 +116,7 @@ export function createBaselineRoutes(store?: BaselineStore): ExpressRouter { * keyPrefix — restrict by baselineId prefix, e.g. `appId/deviceId` */ router.get('/', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const {status, keyPrefix} = req.query as { status?: string; keyPrefix?: string; @@ -118,7 +124,7 @@ export function createBaselineRoutes(store?: BaselineStore): ExpressRouter { const list = s.listBaselines({ status: status as BaselineRecord['status'] | undefined, keyPrefix, - }); + }, scope); return res.json({success: true, baselines: list, count: list.length}); }); diff --git a/backend/src/routes/caseRoutes.ts b/backend/src/routes/caseRoutes.ts index 3177f4c4..1de791ed 100644 --- a/backend/src/routes/caseRoutes.ts +++ b/backend/src/routes/caseRoutes.ts @@ -27,8 +27,10 @@ import * as path from 'path'; import {Router, type Router as ExpressRouter} from 'express'; +import {authenticate, requireRequestContext} from '../middleware/auth'; import {CaseLibrary} from '../services/caseLibrary'; import {CaseGraph} from '../services/caseGraph'; +import {knowledgeScopeFromRequestContext} from '../services/scopedKnowledgeStore'; import type { CaseEdge, CaseEducationalLevel, @@ -65,18 +67,21 @@ export function createCaseRoutes( const lib = library ?? getDefaultLibrary(); const g = graph ?? getDefaultGraph(); const router = Router(); + router.use(authenticate); // ------------------------------------------------------------------- // Edge endpoints — registered BEFORE the `/:caseId` routes so the // literal "edges" path segment isn't captured as a caseId. // ------------------------------------------------------------------- - router.get('/edges', (_req, res) => { - const edges = g.listEdges(); + router.get('/edges', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + const edges = g.listEdges(scope); res.json({success: true, edges, count: edges.length}); }); router.post('/edges', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const edge = req.body as CaseEdge | undefined; if ( !edge || @@ -92,7 +97,7 @@ export function createCaseRoutes( }); } try { - g.addEdge(edge); + g.addEdge(edge, scope); return res.status(201).json({success: true, edge}); } catch (err) { return res.status(400).json({ @@ -103,16 +108,21 @@ export function createCaseRoutes( }); router.get('/edges/:caseId', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const direction = (req.query.direction ?? 'both') as | 'in' | 'out' | 'both'; - const related = g.findRelated(req.params.caseId, {direction}); + const related = g.findRelated(req.params.caseId, { + direction, + knowledgeScope: scope, + }); res.json({success: true, related, count: related.length}); }); router.delete('/edges/:edgeId', (req, res) => { - const removed = g.removeEdge(req.params.edgeId); + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + const removed = g.removeEdge(req.params.edgeId, scope); if (!removed) { return res.status(404).json({ success: false, @@ -127,6 +137,7 @@ export function createCaseRoutes( // ------------------------------------------------------------------- router.get('/', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const {status, tag, level} = req.query as { status?: string; tag?: string; @@ -136,11 +147,12 @@ export function createCaseRoutes( status: status as CurationStatus | undefined, anyOfTags: tag ? [tag] : undefined, educationalLevel: level as CaseEducationalLevel | undefined, - }); + }, scope); res.json({success: true, cases, count: cases.length}); }); router.post('/', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const c = req.body as CaseNode | undefined; if (!c || !c.caseId || !c.title || !c.status) { return res.status(400).json({ @@ -149,7 +161,7 @@ export function createCaseRoutes( }); } try { - lib.saveCase(c); + lib.saveCase(c, scope); return res.status(201).json({success: true, case: c}); } catch (err) { return res.status(400).json({ @@ -160,7 +172,8 @@ export function createCaseRoutes( }); router.get('/:caseId', (req, res) => { - const c = lib.getCase(req.params.caseId); + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + const c = lib.getCase(req.params.caseId, scope); if (!c) { return res.status(404).json({ success: false, @@ -171,7 +184,8 @@ export function createCaseRoutes( }); router.delete('/:caseId', (req, res) => { - const removed = lib.removeCase(req.params.caseId); + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + const removed = lib.removeCase(req.params.caseId, scope); if (!removed) { return res.status(404).json({ success: false, @@ -183,9 +197,10 @@ export function createCaseRoutes( /** POST /api/cases/:caseId/publish — body `{reviewer}`. */ router.post('/:caseId/publish', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const reviewer = (req.body?.reviewer ?? '') as string; try { - const published = lib.publishCase(req.params.caseId, {reviewer}); + const published = lib.publishCase(req.params.caseId, {reviewer}, scope); return res.json({success: true, case: published}); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -196,9 +211,10 @@ export function createCaseRoutes( /** POST /api/cases/:caseId/archive — body `{reason}`. */ router.post('/:caseId/archive', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const reason = (req.body?.reason ?? '') as string; try { - const archived = lib.archiveCase(req.params.caseId, {reason}); + const archived = lib.archiveCase(req.params.caseId, {reason}, scope); return res.json({success: true, case: archived}); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/backend/src/routes/ciGateRoutes.ts b/backend/src/routes/ciGateRoutes.ts index 9eb1d4e4..c6a98b57 100644 --- a/backend/src/routes/ciGateRoutes.ts +++ b/backend/src/routes/ciGateRoutes.ts @@ -27,6 +27,7 @@ import * as path from 'path'; import {Router, type Router as ExpressRouter} from 'express'; +import {requireRequestContext} from '../middleware/auth'; import { computeBaselineDiff, evaluateRegressionGate, @@ -35,6 +36,10 @@ import { } from '../services/baselineDiffer'; import {BaselineStore} from '../services/baselineStore'; import {CiGateRunStore} from '../services/ciGateRunStore'; +import { + type KnowledgeScope, + knowledgeScopeFromRequestContext, +} from '../services/scopedKnowledgeStore'; import type { CiGateRunCandidateSnapshot, CiGateRunRecord, @@ -109,6 +114,7 @@ export function createCiGateRoutes(deps: CiGateRoutesDeps = {}): ExpressRouter { router.post('/gate-eval', (req, res) => { const baselineStore = resolveBaselineStore(); const runStore = resolveRunStore(); + const storageScope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const body = (req.body ?? {}) as EvalBody; const gateId = stringField(body.gateId); @@ -140,7 +146,11 @@ export function createCiGateRoutes(deps: CiGateRoutesDeps = {}): ExpressRouter { }); } - const candidateOrError = parseCandidate(body.candidate, baselineStore); + const candidateOrError = parseCandidate( + body.candidate, + baselineStore, + storageScope, + ); if ('error' in candidateOrError) { return res .status(400) @@ -149,7 +159,7 @@ export function createCiGateRoutes(deps: CiGateRoutesDeps = {}): ExpressRouter { const candidate = candidateOrError.candidate; const ciContext = parseCiContext(body.ciContext); - const baseline = baselineStore.getBaseline(baselineId); + const baseline = baselineStore.getBaseline(baselineId, storageScope); if (!baseline) { const skipped = makeSkippedRun({ @@ -310,6 +320,7 @@ function parseRules(value: unknown): RegressionRule[] | undefined { function parseCandidate( value: unknown, baselineStore: BaselineStore, + storageScope?: KnowledgeScope, ): | {candidate: ResolvedCandidate} | {error: string} { @@ -341,7 +352,10 @@ function parseCandidate( if (!candidateBaselineId) { return {error: "candidate.baselineId is required for kind='baseline'"}; } - const candidateBaseline = baselineStore.getBaseline(candidateBaselineId); + const candidateBaseline = baselineStore.getBaseline( + candidateBaselineId, + storageScope, + ); if (!candidateBaseline) { return { error: `candidate baseline '${candidateBaselineId}' not found`, diff --git a/backend/src/routes/memoryRoutes.ts b/backend/src/routes/memoryRoutes.ts index c3f938bf..24bd90b5 100644 --- a/backend/src/routes/memoryRoutes.ts +++ b/backend/src/routes/memoryRoutes.ts @@ -30,7 +30,9 @@ import * as path from 'path'; import {Router, type Router as ExpressRouter} from 'express'; +import {authenticate, requireRequestContext} from '../middleware/auth'; import {ProjectMemory} from '../agentv3/projectMemory'; +import {knowledgeScopeFromRequestContext} from '../services/scopedKnowledgeStore'; import type {MemoryPromotionPolicy} from '../types/sparkContracts'; const DEFAULT_STORAGE_PATH = path.resolve( @@ -49,6 +51,7 @@ function getDefaultMemory(): ProjectMemory { export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter { const m = memory ?? getDefaultMemory(); const router = Router(); + router.use(authenticate); /** * GET /api/memory @@ -57,6 +60,7 @@ export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter { * Lists entries deterministically; returns the count alongside. */ router.get('/', (req, res) => { + const storageScope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const {scope, projectKey, tag} = req.query as { scope?: string; projectKey?: string; @@ -68,7 +72,7 @@ export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter { scope: filterScope, projectKey, anyOfTags: tag ? [tag] : undefined, - }); + }, storageScope); res.json({success: true, entries, count: entries.length}); }); @@ -79,6 +83,7 @@ export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter { * removal so the reviewer trail stays permanent. */ router.get('/audit', (_req, res) => { + requireRequestContext(_req); const audit = m.getPromotionAudit(); res.json({success: true, audit, count: audit.length}); }); @@ -95,6 +100,7 @@ export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter { * surface from §4.5. Not exposed to the agent. */ router.post('/promote', (req, res) => { + const storageScope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const {entryId, policy} = (req.body ?? {}) as { entryId?: string; policy?: MemoryPromotionPolicy; @@ -106,8 +112,8 @@ export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter { }); } try { - m.promoteEntry(entryId, policy); - const entry = m.getProjectMemoryEntry(entryId); + m.promoteEntry(entryId, policy, storageScope); + const entry = m.getProjectMemoryEntry(entryId, storageScope); return res.status(200).json({success: true, entry}); } catch (err) { return res.status(400).json({ @@ -119,7 +125,8 @@ export function createMemoryRoutes(memory?: ProjectMemory): ExpressRouter { /** DELETE /api/memory/:entryId */ router.delete('/:entryId', (req, res) => { - const removed = m.removeProjectMemoryEntry(req.params.entryId); + const storageScope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + const removed = m.removeProjectMemoryEntry(req.params.entryId, storageScope); if (!removed) { return res.status(404).json({ success: false, diff --git a/backend/src/routes/ragAdminRoutes.ts b/backend/src/routes/ragAdminRoutes.ts index 43c3b76e..8d642576 100644 --- a/backend/src/routes/ragAdminRoutes.ts +++ b/backend/src/routes/ragAdminRoutes.ts @@ -30,7 +30,9 @@ import * as path from 'path'; import {Router, type Router as ExpressRouter} from 'express'; +import {authenticate, requireRequestContext} from '../middleware/auth'; import {RagStore} from '../services/ragStore'; +import {knowledgeScopeFromRequestContext} from '../services/scopedKnowledgeStore'; import type {RagSourceKind} from '../types/sparkContracts'; const DEFAULT_STORAGE_PATH = path.resolve( @@ -48,13 +50,16 @@ function getDefaultStore(): RagStore { export function createRagAdminRoutes(store?: RagStore): ExpressRouter { const s = store ?? getDefaultStore(); const router = Router(); + router.use(authenticate); - router.get('/stats', (_req, res) => { - res.json({success: true, stats: s.getStats()}); + router.get('/stats', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + res.json({success: true, stats: s.getStats(scope)}); }); router.get('/chunks/:chunkId', (req, res) => { - const chunk = s.getChunk(req.params.chunkId); + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + const chunk = s.getChunk(req.params.chunkId, scope); if (!chunk) { return res.status(404).json({ success: false, @@ -65,7 +70,8 @@ export function createRagAdminRoutes(store?: RagStore): ExpressRouter { }); router.delete('/chunks/:chunkId', (req, res) => { - const removed = s.removeChunk(req.params.chunkId); + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); + const removed = s.removeChunk(req.params.chunkId, scope); if (!removed) { return res.status(404).json({ success: false, @@ -76,6 +82,7 @@ export function createRagAdminRoutes(store?: RagStore): ExpressRouter { }); router.post('/search', (req, res) => { + const scope = knowledgeScopeFromRequestContext(requireRequestContext(req)); const {query, kinds, topK} = (req.body ?? {}) as { query?: string; kinds?: RagSourceKind[]; @@ -90,6 +97,7 @@ export function createRagAdminRoutes(store?: RagStore): ExpressRouter { const result = s.search(query, { ...(kinds ? {kinds} : {}), ...(topK ? {topK} : {}), + scope, }); res.json({success: true, result}); }); diff --git a/backend/src/services/__tests__/enterpriseKnowledgeScope.test.ts b/backend/src/services/__tests__/enterpriseKnowledgeScope.test.ts new file mode 100644 index 00000000..6c45ff87 --- /dev/null +++ b/backend/src/services/__tests__/enterpriseKnowledgeScope.test.ts @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {describe, it, expect, beforeEach, afterEach} from '@jest/globals'; + +import {ENTERPRISE_FEATURE_FLAG_ENV} from '../../config'; +import {ProjectMemory} from '../../agentv3/projectMemory'; +import {BaselineStore, deriveBaselineId} from '../baselineStore'; +import {CaseGraph} from '../caseGraph'; +import {CaseLibrary} from '../caseLibrary'; +import {ENTERPRISE_DB_PATH_ENV, openEnterpriseDb} from '../enterpriseDb'; +import {RagStore} from '../ragStore'; +import type {KnowledgeScope} from '../scopedKnowledgeStore'; +import { + type BaselineRecord, + type CaseEdge, + type CaseNode, + type PerfBaselineKey, + type ProjectMemoryEntry, + type RagChunk, + makeSparkProvenance, +} from '../../types/sparkContracts'; + +const originalEnv = { + enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV], + enterpriseDbPath: process.env[ENTERPRISE_DB_PATH_ENV], +}; + +const scopeA: KnowledgeScope = { + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'user-a', +}; +const scopeB: KnowledgeScope = { + tenantId: 'tenant-b', + workspaceId: 'workspace-b', + userId: 'user-b', +}; +const systemScope: KnowledgeScope = { + tenantId: 'system', + workspaceId: 'system', + userId: 'system', +}; + +let tmpDir: string; +let dbPath: string; + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function makeChunk(overrides: Partial = {}): RagChunk { + return { + chunkId: 'shared-chunk', + kind: 'androidperformance.com', + uri: 'https://androidperformance.com/test', + snippet: 'binder latency tenant a', + indexedAt: 1714600000000, + ...overrides, + }; +} + +const BASELINE_KEY: PerfBaselineKey = { + appId: 'anon-app', + deviceId: 'anon-device', + buildId: 'main', + cuj: 'scroll', +}; + +function makeBaseline( + overrides: Partial = {}, +): BaselineRecord { + return { + ...makeSparkProvenance({source: 'enterprise-knowledge-scope-test'}), + baselineId: deriveBaselineId(BASELINE_KEY), + artifactId: 'artifact-1', + capturedAt: 1714600000000, + sampleCount: 5, + key: BASELINE_KEY, + status: 'reviewed', + redactionState: 'raw', + windowStartMs: 1714000000000, + windowEndMs: 1714600000000, + metrics: [], + ...overrides, + }; +} + +function makeMemoryEntry( + overrides: Partial = {}, +): ProjectMemoryEntry { + return { + entryId: 'shared-memory', + scope: 'project', + projectKey: 'anon-app/anon-device', + tags: ['scrolling'], + insight: 'tenant a memory', + confidence: 0.8, + status: 'provisional', + createdAt: 1714600000000, + ...overrides, + }; +} + +function makeCase(overrides: Partial = {}): CaseNode { + return { + ...makeSparkProvenance({source: 'enterprise-knowledge-scope-test'}), + caseId: 'shared-case', + title: 'Tenant A case', + status: 'draft', + redactionState: 'raw', + tags: ['scrolling'], + findings: [{id: 'f1', severity: 'warning', title: 'Frame jitter'}], + ...overrides, + }; +} + +function makeEdge(overrides: Partial = {}): CaseEdge { + return { + edgeId: 'shared-edge', + fromCaseId: 'shared-case', + toCaseId: 'related-case', + relation: 'similar_root_cause', + weight: 0.9, + ...overrides, + }; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enterprise-knowledge-scope-')); + dbPath = path.join(tmpDir, 'enterprise.sqlite'); + process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true'; + process.env[ENTERPRISE_DB_PATH_ENV] = dbPath; +}); + +afterEach(() => { + restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise); + restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalEnv.enterpriseDbPath); + fs.rmSync(tmpDir, {recursive: true, force: true}); +}); + +describe('enterprise knowledge scope', () => { + it('filters RAG candidates by tenant/workspace before keyword retrieval', () => { + const store = new RagStore(path.join(tmpDir, 'rag.json')); + store.addChunk(makeChunk({snippet: 'binder latency tenant a'}), scopeA); + store.addChunk(makeChunk({snippet: 'binder latency tenant b'}), scopeB); + store.addChunk( + makeChunk({ + chunkId: 'system-chunk', + snippet: 'binder latency system knowledge', + }), + systemScope, + ); + + const result = store.search('binder latency', {scope: scopeA}); + + expect(result.results.map(hit => hit.chunk?.snippet).sort()).toEqual([ + 'binder latency system knowledge', + 'binder latency tenant a', + ]); + expect(result.results.map(hit => hit.chunk?.snippet)).not.toContain( + 'binder latency tenant b', + ); + }); + + it('keeps baseline, memory, case, and case graph rows isolated by scope', () => { + const baselineStore = new BaselineStore(path.join(tmpDir, 'baselines.json')); + const memoryStore = new ProjectMemory(path.join(tmpDir, 'memory.json')); + const caseLibrary = new CaseLibrary(path.join(tmpDir, 'cases.json')); + const caseGraph = new CaseGraph(path.join(tmpDir, 'edges.json')); + + baselineStore.addBaseline( + makeBaseline({curatorNote: 'tenant-a'}), + scopeA, + ); + baselineStore.addBaseline( + makeBaseline({curatorNote: 'tenant-b'}), + scopeB, + ); + memoryStore.saveProjectMemoryEntry( + makeMemoryEntry({insight: 'tenant a memory'}), + scopeA, + ); + memoryStore.saveProjectMemoryEntry( + makeMemoryEntry({insight: 'tenant b memory'}), + scopeB, + ); + caseLibrary.saveCase(makeCase({title: 'Tenant A case'}), scopeA); + caseLibrary.saveCase(makeCase({title: 'Tenant B case'}), scopeB); + caseGraph.addEdge(makeEdge({note: 'tenant-a'}), scopeA); + caseGraph.addEdge(makeEdge({note: 'tenant-b'}), scopeB); + + expect( + baselineStore.getBaseline(deriveBaselineId(BASELINE_KEY), scopeA) + ?.curatorNote, + ).toBe('tenant-a'); + expect( + baselineStore.getBaseline(deriveBaselineId(BASELINE_KEY), scopeB) + ?.curatorNote, + ).toBe('tenant-b'); + expect( + memoryStore.recallProjectMemory({tags: ['scrolling']}, scopeA) + .map(hit => hit.entry.insight), + ).toEqual(['tenant a memory']); + expect(caseLibrary.listCases({}, scopeA).map(c => c.title)).toEqual([ + 'Tenant A case', + ]); + expect(caseGraph.listEdges(scopeA).map(edge => edge.note)).toEqual([ + 'tenant-a', + ]); + + const db = openEnterpriseDb(dbPath); + try { + const rows = db.prepare(` + SELECT tenant_id, workspace_id, scope + FROM memory_entries + ORDER BY tenant_id, workspace_id, scope + `).all(); + expect(rows).toEqual(expect.arrayContaining([ + {tenant_id: 'tenant-a', workspace_id: 'workspace-a', scope: 'baseline'}, + {tenant_id: 'tenant-a', workspace_id: 'workspace-a', scope: 'memory:project'}, + {tenant_id: 'tenant-a', workspace_id: 'workspace-a', scope: 'case:draft'}, + {tenant_id: 'tenant-a', workspace_id: 'workspace-a', scope: 'case_edge:similar_root_cause'}, + {tenant_id: 'tenant-b', workspace_id: 'workspace-b', scope: 'baseline'}, + {tenant_id: 'tenant-b', workspace_id: 'workspace-b', scope: 'memory:project'}, + ])); + } finally { + db.close(); + } + }); +}); diff --git a/backend/src/services/baselineStore.ts b/backend/src/services/baselineStore.ts index 07c8dc04..be044978 100644 --- a/backend/src/services/baselineStore.ts +++ b/backend/src/services/baselineStore.ts @@ -33,6 +33,14 @@ import { type BaselineRecord, type PerfBaselineKey, } from '../types/sparkContracts'; +import { + enterpriseKnowledgeStoreEnabled, + type KnowledgeScope, + getScopedKnowledgeRecord, + listScopedKnowledgeRecords, + removeScopedKnowledgeRecord, + upsertScopedKnowledgeRecord, +} from './scopedKnowledgeStore'; /** Minimum sample count enforced for `status='published'`. */ export const BASELINE_PUBLISH_MIN_SAMPLES = 3; @@ -44,6 +52,9 @@ interface StorageEnvelope { baselines: BaselineRecord[]; } +const KNOWLEDGE_KIND = 'baseline'; +const BASELINE_ROW_SCOPE = 'baseline'; + /** Normalize an appId or deviceId to detect when it carries * identifiable raw info. Heuristic: package-style ids * (`com.example.feed`) or model+os fingerprints (`pixel-9-android-15`) @@ -118,21 +129,45 @@ export class BaselineStore { * before writing — invalid records throw rather than silently * downgrading status, so the operator sees the failure. */ - addBaseline(record: BaselineRecord): void { + addBaseline(record: BaselineRecord, scope?: KnowledgeScope): void { this.load(); this.assertPublishInvariants(record); + if (enterpriseKnowledgeStoreEnabled()) { + upsertScopedKnowledgeRecord( + KNOWLEDGE_KIND, + record.baselineId, + BASELINE_ROW_SCOPE, + record, + scope, + {createdAt: record.capturedAt, updatedAt: Date.now()}, + ); + return; + } this.baselines.set(record.baselineId, record); this.persist(); } /** Get a baseline by id. */ - getBaseline(baselineId: string): BaselineRecord | undefined { + getBaseline( + baselineId: string, + scope?: KnowledgeScope, + ): BaselineRecord | undefined { + if (enterpriseKnowledgeStoreEnabled()) { + return getScopedKnowledgeRecord( + KNOWLEDGE_KIND, + baselineId, + scope, + )?.record; + } this.load(); return this.baselines.get(baselineId); } /** Remove a baseline. Returns whether anything was actually removed. */ - removeBaseline(baselineId: string): boolean { + removeBaseline(baselineId: string, scope?: KnowledgeScope): boolean { + if (enterpriseKnowledgeStoreEnabled()) { + return removeScopedKnowledgeRecord(KNOWLEDGE_KIND, baselineId, scope); + } this.load(); const had = this.baselines.delete(baselineId); if (had) this.persist(); @@ -143,9 +178,18 @@ export class BaselineStore { * List baselines with optional filters. Results are stable-ordered * by baselineId for deterministic consumers. */ - listBaselines(opts: BaselineStoreListOptions = {}): BaselineRecord[] { + listBaselines( + opts: BaselineStoreListOptions = {}, + scope?: KnowledgeScope, + ): BaselineRecord[] { this.load(); - let out = Array.from(this.baselines.values()); + let out = enterpriseKnowledgeStoreEnabled() + ? listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + scope, + {rowScope: BASELINE_ROW_SCOPE}, + ).map(row => row.record) + : Array.from(this.baselines.values()); if (opts.status) { out = out.filter(b => b.status === opts.status); } diff --git a/backend/src/services/caseGraph.ts b/backend/src/services/caseGraph.ts index c99c2761..477e8690 100644 --- a/backend/src/services/caseGraph.ts +++ b/backend/src/services/caseGraph.ts @@ -38,12 +38,22 @@ import * as fs from 'fs'; import * as path from 'path'; import {type CaseEdge} from '../types/sparkContracts'; +import { + enterpriseKnowledgeStoreEnabled, + type KnowledgeScope, + listScopedKnowledgeRecords, + removeScopedKnowledgeRecord, + upsertScopedKnowledgeRecord, +} from './scopedKnowledgeStore'; interface StorageEnvelope { schemaVersion: 1; edges: CaseEdge[]; } +const KNOWLEDGE_KIND = 'case_edge'; +const CASE_EDGE_ROW_SCOPE_PREFIX = 'case_edge:'; + /** Build the canonical dedup key for an edge. */ function edgeKey(edge: Pick): string { return `${edge.fromCaseId}|${edge.toCaseId}|${edge.relation}`; @@ -57,6 +67,8 @@ export interface FindRelatedOptions { direction?: 'out' | 'in' | 'both'; /** Maximum hits returned. Defaults to 10. */ topK?: number; + /** Enterprise tenant/workspace scope. Ignored by legacy JSON storage. */ + knowledgeScope?: KnowledgeScope; } /** @@ -92,19 +104,44 @@ export class CaseGraph { * same `(from, to, relation)` triplet replaces weight + note + * edgeId (so callers can keep the latest curator note). */ - addEdge(edge: CaseEdge): void { + addEdge(edge: CaseEdge, scope?: KnowledgeScope): void { this.load(); if (edge.fromCaseId === edge.toCaseId) { throw new Error( `Self-loops are not permitted: edge '${edge.edgeId}' has fromCaseId === toCaseId === '${edge.fromCaseId}'`, ); } + if (enterpriseKnowledgeStoreEnabled()) { + for (const existing of this.listEnterpriseEdges(scope)) { + if ( + edgeKey(existing.record) === edgeKey(edge) && + existing.record.edgeId !== edge.edgeId + ) { + removeScopedKnowledgeRecord( + KNOWLEDGE_KIND, + existing.record.edgeId, + scope, + ); + } + } + upsertScopedKnowledgeRecord( + KNOWLEDGE_KIND, + edge.edgeId, + caseEdgeRowScope(edge.relation), + edge, + scope, + ); + return; + } this.edges.set(edgeKey(edge), edge); this.persist(); } /** Remove an edge by canonical id. Returns whether it was present. */ - removeEdge(edgeId: string): boolean { + removeEdge(edgeId: string, scope?: KnowledgeScope): boolean { + if (enterpriseKnowledgeStoreEnabled()) { + return removeScopedKnowledgeRecord(KNOWLEDGE_KIND, edgeId, scope); + } this.load(); let foundKey: string | undefined; for (const [k, e] of this.edges) { @@ -120,7 +157,13 @@ export class CaseGraph { } /** Get all edges originating at the case. */ - getEdgesFrom(caseId: string): CaseEdge[] { + getEdgesFrom(caseId: string, scope?: KnowledgeScope): CaseEdge[] { + if (enterpriseKnowledgeStoreEnabled()) { + return this.listEnterpriseEdges(scope) + .map(row => row.record) + .filter(e => e.fromCaseId === caseId) + .sort((a, b) => a.edgeId.localeCompare(b.edgeId)); + } this.load(); return Array.from(this.edges.values()) .filter(e => e.fromCaseId === caseId) @@ -128,7 +171,13 @@ export class CaseGraph { } /** Get all edges pointing at the case. */ - getEdgesTo(caseId: string): CaseEdge[] { + getEdgesTo(caseId: string, scope?: KnowledgeScope): CaseEdge[] { + if (enterpriseKnowledgeStoreEnabled()) { + return this.listEnterpriseEdges(scope) + .map(row => row.record) + .filter(e => e.toCaseId === caseId) + .sort((a, b) => a.edgeId.localeCompare(b.edgeId)); + } this.load(); return Array.from(this.edges.values()) .filter(e => e.toCaseId === caseId) @@ -151,7 +200,10 @@ export class CaseGraph { const topK = opts.topK ?? 10; const candidates: Array<{caseId: string; edge: CaseEdge}> = []; - for (const e of this.edges.values()) { + const edges = enterpriseKnowledgeStoreEnabled() + ? this.listEnterpriseEdges(opts.knowledgeScope).map(row => row.record) + : Array.from(this.edges.values()); + for (const e of edges) { if (relations && !relations.has(e.relation)) continue; if ( (direction === 'out' || direction === 'both') && @@ -180,7 +232,12 @@ export class CaseGraph { /** All edges, deterministically ordered by canonical key. Used by * the export bundler that joins this with the case library. */ - listEdges(): CaseEdge[] { + listEdges(scope?: KnowledgeScope): CaseEdge[] { + if (enterpriseKnowledgeStoreEnabled()) { + return this.listEnterpriseEdges(scope) + .map(row => row.record) + .sort((a, b) => edgeKey(a).localeCompare(edgeKey(b))); + } this.load(); return Array.from(this.edges.values()).sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)), @@ -188,7 +245,10 @@ export class CaseGraph { } /** Total edge count. */ - size(): number { + size(scope?: KnowledgeScope): number { + if (enterpriseKnowledgeStoreEnabled()) { + return this.listEnterpriseEdges(scope).length; + } this.load(); return this.edges.size; } @@ -205,4 +265,16 @@ export class CaseGraph { fs.writeFileSync(tmp, JSON.stringify(envelope, null, 2), 'utf-8'); fs.renameSync(tmp, this.storagePath); } + + private listEnterpriseEdges(scope?: KnowledgeScope) { + return listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + scope, + {rowScopePrefix: CASE_EDGE_ROW_SCOPE_PREFIX}, + ); + } +} + +function caseEdgeRowScope(relation: string): string { + return `${CASE_EDGE_ROW_SCOPE_PREFIX}${relation}`; } diff --git a/backend/src/services/caseLibrary.ts b/backend/src/services/caseLibrary.ts index 006b9752..17c2b328 100644 --- a/backend/src/services/caseLibrary.ts +++ b/backend/src/services/caseLibrary.ts @@ -35,12 +35,23 @@ import { type CurationStatus, makeSparkProvenance, } from '../types/sparkContracts'; +import { + enterpriseKnowledgeStoreEnabled, + type KnowledgeScope, + getScopedKnowledgeRecord, + listScopedKnowledgeRecords, + removeScopedKnowledgeRecord, + upsertScopedKnowledgeRecord, +} from './scopedKnowledgeStore'; interface StorageEnvelope { schemaVersion: 1; cases: CaseNode[]; } +const KNOWLEDGE_KIND = 'case_node'; +const CASE_ROW_SCOPE_PREFIX = 'case:'; + export interface ListOptions { status?: CurationStatus; /** Restrict to cases whose tag set overlaps with at least one of these. */ @@ -91,32 +102,62 @@ export class CaseLibrary { * is the dedicated `publishCase()` call so the gate cannot be * bypassed by a field update. */ - saveCase(record: CaseNode): void { + saveCase(record: CaseNode, scope?: KnowledgeScope): void { this.load(); if (record.status === 'published') { throw new Error( `Use publishCase() to advance a case to 'published'; saveCase() rejects published records to keep the gate auditable`, ); } + if (enterpriseKnowledgeStoreEnabled()) { + upsertScopedKnowledgeRecord( + KNOWLEDGE_KIND, + record.caseId, + caseRowScope(record.status), + record, + scope, + {createdAt: record.createdAt, updatedAt: Date.now()}, + ); + return; + } this.cases.set(record.caseId, record); this.persist(); } - getCase(caseId: string): CaseNode | undefined { + getCase(caseId: string, scope?: KnowledgeScope): CaseNode | undefined { + if (enterpriseKnowledgeStoreEnabled()) { + return getScopedKnowledgeRecord( + KNOWLEDGE_KIND, + caseId, + scope, + )?.record; + } this.load(); return this.cases.get(caseId); } - removeCase(caseId: string): boolean { + removeCase(caseId: string, scope?: KnowledgeScope): boolean { + if (enterpriseKnowledgeStoreEnabled()) { + return removeScopedKnowledgeRecord(KNOWLEDGE_KIND, caseId, scope); + } this.load(); const had = this.cases.delete(caseId); if (had) this.persist(); return had; } - listCases(opts: ListOptions = {}): CaseNode[] { + listCases(opts: ListOptions = {}, scope?: KnowledgeScope): CaseNode[] { this.load(); - let out = Array.from(this.cases.values()); + let out = enterpriseKnowledgeStoreEnabled() + ? listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + scope, + { + rowScope: opts.status ? caseRowScope(opts.status) : undefined, + rowScopePrefix: opts.status ? undefined : CASE_ROW_SCOPE_PREFIX, + }, + ).map(row => row.record) + : Array.from(this.cases.values()); if (opts.status) out = out.filter(c => c.status === opts.status); if (opts.educationalLevel) out = out.filter(c => c.educationalLevel === opts.educationalLevel); @@ -139,7 +180,11 @@ export class CaseLibrary { * without a follow-up read. Stamps `curatedBy` / `curatedAt` from * the reviewer + wall clock. */ - publishCase(caseId: string, opts: PublishOptions): CaseNode { + publishCase( + caseId: string, + opts: PublishOptions, + scope?: KnowledgeScope, + ): CaseNode { this.load(); const trimmedReviewer = opts.reviewer?.trim(); if (!trimmedReviewer) { @@ -147,7 +192,13 @@ export class CaseLibrary { `Cannot publish case '${caseId}' without a reviewer signoff`, ); } - const existing = this.cases.get(caseId); + const existing = enterpriseKnowledgeStoreEnabled() + ? getScopedKnowledgeRecord( + KNOWLEDGE_KIND, + caseId, + scope, + )?.record + : this.cases.get(caseId); if (!existing) { throw new Error(`Cannot publish case '${caseId}': not found`); } @@ -162,6 +213,17 @@ export class CaseLibrary { curatedBy: trimmedReviewer, curatedAt: Date.now(), }; + if (enterpriseKnowledgeStoreEnabled()) { + upsertScopedKnowledgeRecord( + KNOWLEDGE_KIND, + caseId, + caseRowScope(published.status), + published, + scope, + {createdAt: published.createdAt, updatedAt: published.curatedAt}, + ); + return published; + } this.cases.set(caseId, published); this.persist(); return published; @@ -174,13 +236,23 @@ export class CaseLibrary { * reason on `traceUnavailableReason` so consumers see why the trace * is gone. */ - archiveCase(caseId: string, opts: ArchiveOptions): CaseNode { + archiveCase( + caseId: string, + opts: ArchiveOptions, + scope?: KnowledgeScope, + ): CaseNode { this.load(); const reason = opts.reason?.trim(); if (!reason) { throw new Error(`archiveCase requires a non-empty reason`); } - const existing = this.cases.get(caseId); + const existing = enterpriseKnowledgeStoreEnabled() + ? getScopedKnowledgeRecord( + KNOWLEDGE_KIND, + caseId, + scope, + )?.record + : this.cases.get(caseId); if (!existing) { throw new Error(`Cannot archive case '${caseId}': not found`); } @@ -193,13 +265,24 @@ export class CaseLibrary { traceArtifactId: undefined, traceUnavailableReason: reason, }; + if (enterpriseKnowledgeStoreEnabled()) { + upsertScopedKnowledgeRecord( + KNOWLEDGE_KIND, + caseId, + caseRowScope(archived.status), + archived, + scope, + {createdAt: archived.createdAt, updatedAt: Date.now()}, + ); + return archived; + } this.cases.set(caseId, archived); this.persist(); return archived; } /** Stats by status — useful for the admin dashboard. */ - getStats(): Record { + getStats(scope?: KnowledgeScope): Record { this.load(); const out: Record = { draft: 0, @@ -207,7 +290,14 @@ export class CaseLibrary { published: 0, private: 0, }; - for (const c of this.cases.values()) out[c.status]++; + const cases = enterpriseKnowledgeStoreEnabled() + ? listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + scope, + {rowScopePrefix: CASE_ROW_SCOPE_PREFIX}, + ).map(row => row.record) + : Array.from(this.cases.values()); + for (const c of cases) out[c.status]++; return out; } @@ -224,3 +314,7 @@ export class CaseLibrary { fs.renameSync(tmp, this.storagePath); } } + +function caseRowScope(status: CurationStatus): string { + return `${CASE_ROW_SCOPE_PREFIX}${status}`; +} diff --git a/backend/src/services/enterpriseRepository.ts b/backend/src/services/enterpriseRepository.ts index e409d350..0457783f 100644 --- a/backend/src/services/enterpriseRepository.ts +++ b/backend/src/services/enterpriseRepository.ts @@ -18,7 +18,8 @@ export type EnterpriseWorkspaceScopedTable = | 'analysis_sessions' | 'analysis_runs' | 'agent_events' - | 'runtime_snapshots'; + | 'runtime_snapshots' + | 'memory_entries'; export const ENTERPRISE_WORKSPACE_SCOPED_TABLES: readonly EnterpriseWorkspaceScopedTable[] = [ 'trace_assets', @@ -26,6 +27,7 @@ export const ENTERPRISE_WORKSPACE_SCOPED_TABLES: readonly EnterpriseWorkspaceSco 'analysis_runs', 'agent_events', 'runtime_snapshots', + 'memory_entries', ]; export type EnterpriseQueryCriteria = Record; diff --git a/backend/src/services/ragStore.ts b/backend/src/services/ragStore.ts index ff98cf64..45265856 100644 --- a/backend/src/services/ragStore.ts +++ b/backend/src/services/ragStore.ts @@ -32,6 +32,14 @@ import { type RagSourceKind, makeSparkProvenance, } from '../types/sparkContracts'; +import { + enterpriseKnowledgeStoreEnabled, + type KnowledgeScope, + getScopedKnowledgeRecord, + listScopedKnowledgeRecords, + removeScopedKnowledgeRecord, + upsertScopedKnowledgeRecord, +} from './scopedKnowledgeStore'; /** Source kinds that require a `license` field at ingestion time. */ const LICENSE_REQUIRED_KINDS: ReadonlySet = new Set([ @@ -59,11 +67,16 @@ interface StorageEnvelope { chunks: RagChunk[]; } +const KNOWLEDGE_KIND = 'rag_chunk'; +const RAG_ROW_SCOPE_PREFIX = 'rag:'; + export interface RagStoreSearchOptions { /** Maximum hits returned. Defaults to 5. */ topK?: number; /** Restrict the search to a subset of source kinds. */ kinds?: RagSourceKind[]; + /** Enterprise tenant/workspace scope. Ignored by legacy JSON storage. */ + scope?: KnowledgeScope; } /** Per-kind index summary returned by `getStats()`. */ @@ -134,19 +147,33 @@ export class RagStore { * the chunk lacks one — the caller is expected to surface this back * to the operator rather than silently dropping the chunk. */ - addChunk(chunk: RagChunk): void { + addChunk(chunk: RagChunk, scope?: KnowledgeScope): void { this.load(); if (LICENSE_REQUIRED_KINDS.has(chunk.kind) && !chunk.license) { throw new Error( `License required for source kind '${chunk.kind}' but missing on chunk '${chunk.chunkId}'`, ); } + if (enterpriseKnowledgeStoreEnabled()) { + upsertScopedKnowledgeRecord( + KNOWLEDGE_KIND, + chunk.chunkId, + ragRowScope(chunk.kind), + chunk, + scope, + {createdAt: chunk.indexedAt, updatedAt: chunk.indexedAt}, + ); + return; + } this.chunks.set(chunk.chunkId, chunk); this.persist(); } /** Remove a chunk. Returns whether anything was actually removed. */ - removeChunk(chunkId: string): boolean { + removeChunk(chunkId: string, scope?: KnowledgeScope): boolean { + if (enterpriseKnowledgeStoreEnabled()) { + return removeScopedKnowledgeRecord(KNOWLEDGE_KIND, chunkId, scope); + } this.load(); const had = this.chunks.delete(chunkId); if (had) this.persist(); @@ -154,16 +181,30 @@ export class RagStore { } /** Get a chunk by id, or undefined when absent. */ - getChunk(chunkId: string): RagChunk | undefined { + getChunk(chunkId: string, scope?: KnowledgeScope): RagChunk | undefined { + if (enterpriseKnowledgeStoreEnabled()) { + return getScopedKnowledgeRecord( + KNOWLEDGE_KIND, + chunkId, + scope, + )?.record; + } this.load(); return this.chunks.get(chunkId); } /** Per-kind chunk counts plus the freshest indexedAt seen for each. */ - getStats(): RagStoreStats { + getStats(scope?: KnowledgeScope): RagStoreStats { this.load(); const stats = emptyStats(); - for (const c of this.chunks.values()) { + const chunks = enterpriseKnowledgeStoreEnabled() + ? listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + scope, + {rowScopePrefix: RAG_ROW_SCOPE_PREFIX, includeSystem: true}, + ).map(row => row.record) + : Array.from(this.chunks.values()); + for (const c of chunks) { const s = stats[c.kind]; s.chunkCount++; if (s.lastIndexedAt === undefined || c.indexedAt > s.lastIndexedAt) { @@ -189,18 +230,23 @@ export class RagStore { this.load(); const topK = opts.topK ?? 5; const kindFilter = opts.kinds ? new Set(opts.kinds) : null; + const chunks = enterpriseKnowledgeStoreEnabled() + ? listScopedKnowledgeRecords( + KNOWLEDGE_KIND, + opts.scope, + {rowScopePrefix: RAG_ROW_SCOPE_PREFIX, includeSystem: true}, + ).map(row => row.record) + : Array.from(this.chunks.values()); const probed = opts.kinds ? [...opts.kinds] - : Array.from( - new Set(Array.from(this.chunks.values()).map(c => c.kind)), - ); + : Array.from(new Set(chunks.map(c => c.kind))); const queryTokens = new Set(tokenize(query)); const candidates: Array<{chunk: RagChunk; score: number}> = []; let eligibleSeen = 0; - for (const chunk of this.chunks.values()) { + for (const chunk of chunks) { if (kindFilter && !kindFilter.has(chunk.kind)) continue; if (chunk.unsupportedReason) continue; eligibleSeen++; @@ -226,7 +272,7 @@ export class RagStore { })); let unsupportedReason: string | undefined; - if (this.chunks.size === 0) { + if (chunks.length === 0) { unsupportedReason = 'index empty'; } else if (eligibleSeen === 0) { // Either every chunk in the probed kinds is blocked, or the kind @@ -273,3 +319,7 @@ export class RagStore { export function ragStoreRequiresLicense(kind: RagSourceKind): boolean { return LICENSE_REQUIRED_KINDS.has(kind); } + +function ragRowScope(kind: RagSourceKind): string { + return `${RAG_ROW_SCOPE_PREFIX}${kind}`; +} diff --git a/backend/src/services/scopedKnowledgeStore.ts b/backend/src/services/scopedKnowledgeStore.ts new file mode 100644 index 00000000..86a20725 --- /dev/null +++ b/backend/src/services/scopedKnowledgeStore.ts @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import crypto from 'crypto'; +import type Database from 'better-sqlite3'; + +import { resolveFeatureConfig } from '../config'; +import type { RequestContext } from '../middleware/auth'; +import { openEnterpriseDb } from './enterpriseDb'; +import { createEnterpriseWorkspaceRepository } from './enterpriseRepository'; + +const DEFAULT_TENANT_ID = 'default-dev-tenant'; +const DEFAULT_WORKSPACE_ID = 'default-workspace'; +const DEFAULT_USER_ID = 'dev-user-123'; +const SAFE_SCOPE_SEGMENT_RE = /^[a-zA-Z0-9._:-]+$/; + +interface KnowledgeEntryRow extends Record { + id: string; + tenant_id: string; + workspace_id: string; + scope: string; + source_run_id: string | null; + content_json: string; + embedding_ref: string | null; + created_at: number; + updated_at: number; +} + +interface KnowledgeEnvelope { + schemaVersion: 1; + kind: string; + externalId: string; + sourceTenantId: string; + sourceWorkspaceId: string; + sourceRunId?: string; + record: T; +} + +export interface KnowledgeScope { + tenantId?: string; + workspaceId?: string; + userId?: string; + sourceRunId?: string; + runId?: string; +} + +export interface ResolvedKnowledgeScope { + tenantId: string; + workspaceId: string; + userId?: string; + sourceRunId?: string; +} + +export interface ScopedKnowledgeRecord { + externalId: string; + rowScope: string; + record: T; + sourceRunId?: string; + createdAt: number; + updatedAt: number; +} + +interface ListOptions { + rowScope?: string; + rowScopePrefix?: string; + includeSystem?: boolean; +} + +interface UpsertOptions { + createdAt?: number; + updatedAt?: number; + sourceRunId?: string; + embeddingRef?: string; +} + +export function enterpriseKnowledgeStoreEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return resolveFeatureConfig(env).enterprise; +} + +export function knowledgeScopeFromRequestContext( + context: RequestContext, +): KnowledgeScope { + return { + tenantId: context.tenantId, + workspaceId: context.workspaceId, + userId: context.userId, + }; +} + +export function resolveKnowledgeScope( + scope: KnowledgeScope = {}, +): ResolvedKnowledgeScope { + const tenantId = sanitizeScopeSegment( + scope.tenantId || DEFAULT_TENANT_ID, + 'tenantId', + ); + const workspaceId = sanitizeScopeSegment( + scope.workspaceId || DEFAULT_WORKSPACE_ID, + 'workspaceId', + ); + const userId = scope.userId + ? sanitizeScopeSegment(scope.userId, 'userId') + : DEFAULT_USER_ID; + const sourceRunId = scope.sourceRunId || scope.runId; + return { + tenantId, + workspaceId, + ...(userId ? {userId} : {}), + ...(sourceRunId + ? {sourceRunId: sanitizeScopeSegment(sourceRunId, 'sourceRunId')} + : {}), + }; +} + +export function scopedKnowledgeRowId( + kind: string, + externalId: string, + scope: Pick, +): string { + const digest = crypto + .createHash('sha256') + .update(`${scope.tenantId}\0${scope.workspaceId}\0${kind}\0${externalId}`) + .digest('hex') + .slice(0, 32); + return `knowledge-${digest}`; +} + +export function upsertScopedKnowledgeRecord( + kind: string, + externalId: string, + rowScope: string, + record: T, + scopeInput?: KnowledgeScope, + opts: UpsertOptions = {}, +): void { + const scope = resolveKnowledgeScope(scopeInput); + const now = Date.now(); + const createdAt = opts.createdAt ?? now; + const updatedAt = opts.updatedAt ?? now; + withKnowledgeDb((db) => { + const tx = db.transaction(() => { + ensureEnterpriseKnowledgeGraph(db, scope); + const sourceRunId = resolveSourceRunId(db, opts.sourceRunId || scope.sourceRunId); + const envelope: KnowledgeEnvelope = { + schemaVersion: 1, + kind, + externalId, + sourceTenantId: scope.tenantId, + sourceWorkspaceId: scope.workspaceId, + ...(sourceRunId ? {sourceRunId} : {}), + record, + }; + db.prepare(` + INSERT INTO memory_entries + (id, tenant_id, workspace_id, scope, source_run_id, content_json, embedding_ref, created_at, updated_at) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + tenant_id = excluded.tenant_id, + workspace_id = excluded.workspace_id, + scope = excluded.scope, + source_run_id = excluded.source_run_id, + content_json = excluded.content_json, + embedding_ref = excluded.embedding_ref, + updated_at = excluded.updated_at + `).run( + scopedKnowledgeRowId(kind, externalId, scope), + scope.tenantId, + scope.workspaceId, + rowScope, + sourceRunId, + JSON.stringify(envelope), + opts.embeddingRef ?? null, + createdAt, + updatedAt, + ); + }); + tx(); + }); +} + +export function getScopedKnowledgeRecord( + kind: string, + externalId: string, + scopeInput?: KnowledgeScope, +): ScopedKnowledgeRecord | undefined { + const scope = resolveKnowledgeScope(scopeInput); + return withKnowledgeDb((db) => { + const repo = createEnterpriseWorkspaceRepository( + db, + 'memory_entries', + ); + const row = repo.getById( + scope, + scopedKnowledgeRowId(kind, externalId, scope), + ); + return row ? parseKnowledgeRow(kind, row) : undefined; + }); +} + +export function removeScopedKnowledgeRecord( + kind: string, + externalId: string, + scopeInput?: KnowledgeScope, +): boolean { + const scope = resolveKnowledgeScope(scopeInput); + return withKnowledgeDb((db) => { + const repo = createEnterpriseWorkspaceRepository( + db, + 'memory_entries', + ); + return repo.deleteById( + scope, + scopedKnowledgeRowId(kind, externalId, scope), + ) > 0; + }); +} + +export function listScopedKnowledgeRecords( + kind: string, + scopeInput?: KnowledgeScope, + opts: ListOptions = {}, +): ScopedKnowledgeRecord[] { + const scope = resolveKnowledgeScope(scopeInput); + return withKnowledgeDb((db) => { + const params: Record = { + tenantId: scope.tenantId, + workspaceId: scope.workspaceId, + systemTenantId: 'system', + systemWorkspaceId: 'system', + }; + const ownerClause = opts.includeSystem + ? `((tenant_id = @tenantId AND workspace_id = @workspaceId) + OR (tenant_id = @systemTenantId AND workspace_id = @systemWorkspaceId))` + : `(tenant_id = @tenantId AND workspace_id = @workspaceId)`; + let scopeClause = ''; + if (opts.rowScope !== undefined) { + params.rowScope = opts.rowScope; + scopeClause = 'AND scope = @rowScope'; + } else if (opts.rowScopePrefix !== undefined) { + params.rowScopePrefix = `${opts.rowScopePrefix}%`; + scopeClause = 'AND scope LIKE @rowScopePrefix'; + } + const rows = db.prepare(` + SELECT * + FROM memory_entries + WHERE ${ownerClause} + ${scopeClause} + ORDER BY updated_at DESC, id ASC + `).all(params); + return rows + .map(row => parseKnowledgeRow(kind, row)) + .filter((record): record is ScopedKnowledgeRecord => Boolean(record)); + }); +} + +function withKnowledgeDb(fn: (db: Database.Database) => T): T { + const db = openEnterpriseDb(); + try { + return fn(db); + } finally { + db.close(); + } +} + +function sanitizeScopeSegment(value: string, label: string): string { + if (!SAFE_SCOPE_SEGMENT_RE.test(value) || value === '.' || value === '..') { + throw new Error(`Unsafe knowledge ${label}: ${value}`); + } + return value; +} + +function ensureEnterpriseKnowledgeGraph( + db: Database.Database, + scope: ResolvedKnowledgeScope, +): void { + const now = Date.now(); + db.prepare(` + INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES (?, ?, 'active', 'enterprise', ?, ?) + `).run(scope.tenantId, scope.tenantId, now, now); + db.prepare(` + INSERT OR IGNORE INTO workspaces (id, tenant_id, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run(scope.workspaceId, scope.tenantId, scope.workspaceId, now, now); + if (scope.userId) { + db.prepare(` + INSERT INTO users (id, tenant_id, email, display_name, idp_subject, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + email = excluded.email, + display_name = excluded.display_name, + updated_at = excluded.updated_at + `).run( + scope.userId, + scope.tenantId, + `${scope.userId}@knowledge.local`, + scope.userId, + `knowledge:${scope.userId}`, + now, + now, + ); + } +} + +function resolveSourceRunId( + db: Database.Database, + sourceRunId?: string, +): string | null { + if (!sourceRunId) return null; + const row = db.prepare(` + SELECT id + FROM analysis_runs + WHERE id = ? + LIMIT 1 + `).get(sourceRunId); + return row ? sourceRunId : null; +} + +function parseKnowledgeRow( + kind: string, + row: KnowledgeEntryRow, +): ScopedKnowledgeRecord | undefined { + try { + const parsed = JSON.parse(row.content_json) as KnowledgeEnvelope; + if ( + parsed.schemaVersion !== 1 || + parsed.kind !== kind || + parsed.externalId === undefined + ) { + return undefined; + } + return { + externalId: parsed.externalId, + rowScope: row.scope, + record: parsed.record, + ...(row.source_run_id ? {sourceRunId: row.source_run_id} : {}), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } catch { + return undefined; + } +} diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index 1926830f..4837e95e 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -45,7 +45,7 @@ - [x] 3.4 report metadata 入 DB;report 内容迁到 `data/{tenantId}/{workspaceId}/reports/`(§14.2) - [x] 3.5 `logs/claude_session_map.json` 迁到 `runtime_snapshots` - [x] 3.6 provider 从 `data/providers.json` 迁到 DB metadata + encrypted SecretStore -- [ ] 3.7 Memory / RAG / Case / Baseline 表加 scope(§14.1,先 filter 后语义召回) +- [x] 3.7 Memory / RAG / Case / Baseline 表加 scope(§14.1,先 filter 后语义召回) - [ ] 3.8 双写 → 切读 → 退役 三阶段(§17),每阶段都能回滚;准备 filesystem + DB snapshot - [ ] 3.9 SecretStore:libsodium 加密 + OS keyring 解 master key + secret rotation + 读取审计 - [ ] 3.10 集成测试:backend restart 后 session/report/trace metadata 可恢复