From 077844056725995ec6d23fc9052098a87ec6d117 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 May 2026 20:13:29 +0800 Subject: [PATCH] feat(enterprise): persist runtime session maps in db --- backend/package.json | 2 +- backend/src/agent/core/orchestratorTypes.ts | 10 + .../claudeRuntimeRuntimeSnapshots.test.ts | 101 +++++++ backend/src/agentv3/claudeRuntime.ts | 134 +++++++-- backend/src/routes/agentRoutes.ts | 5 + .../__tests__/runtimeSnapshotStore.test.ts | 178 ++++++++++++ backend/src/services/enterpriseRepository.ts | 4 +- backend/src/services/runtimeSnapshotStore.ts | 273 ++++++++++++++++++ .../enterprise-multi-tenant/README.md | 2 +- 9 files changed, 689 insertions(+), 20 deletions(-) create mode 100644 backend/src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts create mode 100644 backend/src/services/__tests__/runtimeSnapshotStore.test.ts create mode 100644 backend/src/services/runtimeSnapshotStore.ts diff --git a/backend/package.json b/backend/package.json index d6376ad3..50e0a9ba 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/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/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__/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/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/agent/core/orchestratorTypes.ts b/backend/src/agent/core/orchestratorTypes.ts index dd498be7..e550dd89 100644 --- a/backend/src/agent/core/orchestratorTypes.ts +++ b/backend/src/agent/core/orchestratorTypes.ts @@ -307,6 +307,16 @@ export interface AnalysisOptions { * from this provider instead of the global active provider. */ providerId?: string | null; + /** + * Enterprise persistence scope supplied by the route layer. + * These fields are internal to backend runtime persistence and are not accepted + * directly from untrusted request bodies. + */ + tenantId?: string; + workspaceId?: string; + userId?: string; + runId?: string; + /** * Pre-queried trace datasets from the frontend (populated by quick-action buttons). * Injected into the AI prompt as Markdown tables so the AI can analyze immediately diff --git a/backend/src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts b/backend/src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts new file mode 100644 index 00000000..b37168b3 --- /dev/null +++ b/backend/src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts @@ -0,0 +1,101 @@ +// 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/promises'; +import os from 'os'; +import path from 'path'; +import { ENTERPRISE_FEATURE_FLAG_ENV } from '../../config'; +import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../../services/enterpriseDb'; +import { saveClaudeSessionMapToRuntimeSnapshots } from '../../services/runtimeSnapshotStore'; +import { ClaudeRuntime } from '../claudeRuntime'; + +const originalEnv = { + enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV], + enterpriseDbPath: process.env[ENTERPRISE_DB_PATH_ENV], +}; + +let tmpDir: string | undefined; +let dbPath: string; + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function runtimeSnapshotCount(): number { + const db = openEnterpriseDb(dbPath); + try { + const row = db.prepare( + 'SELECT COUNT(*) AS count FROM runtime_snapshots', + ).get(); + return row?.count ?? 0; + } finally { + db.close(); + } +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-claude-runtime-snapshot-')); + dbPath = path.join(tmpDir, 'enterprise.sqlite'); + process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true'; + process.env[ENTERPRISE_DB_PATH_ENV] = dbPath; +}); + +afterEach(async () => { + restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise); + restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalEnv.enterpriseDbPath); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } +}); + +describe('ClaudeRuntime enterprise runtime_snapshots session map', () => { + it('loads SDK session mappings from runtime_snapshots on construction', () => { + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'user-a', + sessionId: 'session-a', + runId: 'run-a', + traceId: 'trace-a', + }, 'session-a', { + sdkSessionId: 'sdk-session-a', + updatedAt: Date.now(), + }); + + const runtime = new ClaudeRuntime({} as any, { + enableVerification: false, + enableSubAgents: false, + }); + + expect(runtime.getSdkSessionId('session-a')).toBe('sdk-session-a'); + }); + + it('removes enterprise runtime_snapshots rows during session cleanup', () => { + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'user-a', + sessionId: 'session-a', + runId: 'run-a', + traceId: 'trace-a', + }, 'session-a', { + sdkSessionId: 'sdk-session-a', + updatedAt: Date.now(), + }); + expect(runtimeSnapshotCount()).toBe(1); + + const runtime = new ClaudeRuntime({} as any, { + enableVerification: false, + enableSubAgents: false, + }); + + runtime.removeSession('session-a'); + expect(runtimeSnapshotCount()).toBe(0); + }); +}); diff --git a/backend/src/agentv3/claudeRuntime.ts b/backend/src/agentv3/claudeRuntime.ts index 7f64a5bc..6443a361 100644 --- a/backend/src/agentv3/claudeRuntime.ts +++ b/backend/src/agentv3/claudeRuntime.ts @@ -83,16 +83,29 @@ import { applyCapturedEntities, } from '../agent/core/entityCapture'; import { DEFAULT_OUTPUT_LANGUAGE, localize } from './outputLanguage'; +import { resolveFeatureConfig } from '../config'; +import { + deleteClaudeSessionMapRuntimeSnapshots, + loadClaudeSessionMapFromRuntimeSnapshots, + saveClaudeSessionMapToRuntimeSnapshots, + type ClaudeSessionMapRuntimeEntry, +} from '../services/runtimeSnapshotStore'; const SESSION_MAP_FILE = path.resolve(__dirname, '../../logs/claude_session_map.json'); /** Max age for session map entries before pruning (24 hours). */ const SESSION_MAP_MAX_AGE_MS = 24 * 60 * 60 * 1000; +/** Claude SDK sessions expire server-side after roughly 4 hours. */ +const SDK_SESSION_FRESHNESS_MS = 4 * 60 * 60 * 1000; interface SessionMapEntry { sdkSessionId: string; updatedAt: number; } +function enterpriseSessionMapStoreEnabled(): boolean { + return resolveFeatureConfig(process.env).enterprise; +} + function loadPersistedSessionMap(): Map { try { if (fs.existsSync(SESSION_MAP_FILE)) { @@ -114,6 +127,25 @@ function loadPersistedSessionMap(): Map { return new Map(); } +function loadSessionMapForCurrentMode(): Map { + if (!enterpriseSessionMapStoreEnabled()) { + return loadPersistedSessionMap(); + } + + try { + const dbMap = loadClaudeSessionMapFromRuntimeSnapshots(SESSION_MAP_MAX_AGE_MS); + if (dbMap.size > 0) return dbMap; + } catch (err) { + console.warn('[ClaudeRuntime] Failed to load runtime_snapshots session map:', (err as Error).message); + } + + const legacyMap = loadPersistedSessionMap(); + if (legacyMap.size > 0) { + console.warn('[ClaudeRuntime] Loaded legacy logs/claude_session_map.json for migration; future enterprise writes use runtime_snapshots'); + } + return legacyMap; +} + /** * 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. @@ -333,7 +365,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { super(); this.traceProcessorService = traceProcessorService; this.config = loadClaudeConfig(config); - this.sessionMap = loadPersistedSessionMap(); + this.sessionMap = loadSessionMapForCurrentMode(); } /** Restore a previously persisted SDK session mapping (e.g., after server restart). */ @@ -356,6 +388,62 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { return this.sessionMap.get(smartPerfettoSessionId)?.sdkSessionId; } + private buildSessionMapKey(sessionId: string, referenceTraceId?: string): string { + return referenceTraceId ? `${sessionId}:ref:${referenceTraceId}` : sessionId; + } + + private persistSessionMapEntry( + sessionId: string, + traceId: string, + sessionMapKey: string, + entry: ClaudeSessionMapRuntimeEntry, + options: AnalysisOptions, + ): void { + if (!enterpriseSessionMapStoreEnabled()) { + savePersistedSessionMap(this.sessionMap); + return; + } + + if (!options.tenantId || !options.workspaceId) { + console.warn('[ClaudeRuntime] Enterprise session map persistence skipped: missing tenant/workspace scope'); + return; + } + + try { + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: options.tenantId, + workspaceId: options.workspaceId, + userId: options.userId, + sessionId, + runId: options.runId, + traceId, + }, sessionMapKey, entry); + } catch (err) { + console.warn('[ClaudeRuntime] Failed to persist session map to runtime_snapshots:', (err as Error).message); + } + } + + private rememberSdkSessionMapping( + sessionId: string, + traceId: string, + sessionMapKey: string, + sdkSessionId: string, + options: AnalysisOptions, + ): void { + const entry = { sdkSessionId, updatedAt: Date.now() }; + this.sessionMap.set(sessionMapKey, entry); + this.persistSessionMapEntry(sessionId, traceId, sessionMapKey, entry, options); + } + + private removeSessionMapEntries(sessionId: string): void { + const referencePrefix = `${sessionId}:ref:`; + for (const key of [...this.sessionMap.keys()]) { + if (key === sessionId || key.startsWith(referencePrefix)) { + this.sessionMap.delete(key); + } + } + } + async analyze( query: string, sessionId: string, @@ -485,7 +573,14 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { }); // Reuse composite key from prepareAnalysisContext for comparison mode session identity isolation - const existingSdkSessionId = this.sessionMap.get(ctx.sessionMapKey)?.sdkSessionId; + const existingSessionMapEntry = this.sessionMap.get(ctx.sessionMapKey); + const existingSdkSessionId = existingSessionMapEntry + && (Date.now() - (existingSessionMapEntry.updatedAt || 0) < SDK_SESSION_FRESHNESS_MS) + ? existingSessionMapEntry.sdkSessionId + : undefined; + if (existingSessionMapEntry && existingSdkSessionId && enterpriseSessionMapStoreEnabled()) { + this.persistSessionMapEntry(sessionId, traceId, ctx.sessionMapKey, existingSessionMapEntry, options); + } // When resuming an SDK session, systemPrompt is ignored by the SDK (mutually exclusive). // Prepend selectionContext directly into the prompt so the AI sees it in the conversation. @@ -669,8 +764,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { if (msg.session_id && !sdkSessionId) { sdkSessionId = msg.session_id; - this.sessionMap.set(ctx.sessionMapKey, { sdkSessionId, updatedAt: Date.now() }); - savePersistedSessionMap(this.sessionMap); + this.rememberSdkSessionMapping(sessionId, traceId, ctx.sessionMapKey, sdkSessionId, options); } // Track sub-agent lifecycle for per-agent timeouts @@ -1586,15 +1680,17 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { timestamp: Date.now(), }); - const sessionMapKey = sessionId; + const sessionMapKey = this.buildSessionMapKey(sessionId, options.referenceTraceId); const sessionMapEntry = this.sessionMap.get(sessionMapKey); - // Apply the same 4h freshness rule as the full path (see `SDK_SESSION_FRESHNESS_MS` below in - // prepareAnalysisContext). A stale quick entry silently resumed here would cause context loss. - const SDK_SESSION_FRESHNESS_MS = 4 * 60 * 60 * 1000; + // Apply the same 4h freshness rule as the full path. A stale quick entry + // silently resumed here would cause context loss. const existingSdkSessionId = sessionMapEntry && (Date.now() - (sessionMapEntry.updatedAt || 0) < SDK_SESSION_FRESHNESS_MS) ? sessionMapEntry.sdkSessionId : undefined; + if (sessionMapEntry && existingSdkSessionId && enterpriseSessionMapStoreEnabled()) { + this.persistSessionMapEntry(sessionId, traceId, sessionMapKey, sessionMapEntry, options); + } const sdkEnv = createSdkEnv(options.providerId); // Prepend pre-queried trace data so the AI skips basic SQL turns in fast mode @@ -1654,8 +1750,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { if (msg.session_id && !quickSdkSessionId) { quickSdkSessionId = msg.session_id; - this.sessionMap.set(sessionMapKey, { sdkSessionId: quickSdkSessionId, updatedAt: Date.now() }); - savePersistedSessionMap(this.sessionMap); + this.rememberSdkSessionMapping(sessionId, traceId, sessionMapKey, quickSdkSessionId, options); } try { bridge(msg); } catch { /* non-fatal */ } @@ -1833,7 +1928,7 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { clearTimeout(pendingTimer); saveTimers.delete(this.sessionMap); } - this.sessionMap.delete(sessionId); + this.removeSessionMapEntries(sessionId); this.artifactStores.delete(sessionId); this.sessionNotes.delete(sessionId); this.sessionSqlErrors.delete(sessionId); @@ -1841,8 +1936,16 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { this.sessionHypotheses.delete(sessionId); this.sessionUncertaintyFlags.delete(sessionId); this.activeAnalyses.delete(sessionId); - // Use immediate save — session is being removed, must persist before cleanup completes - savePersistedSessionMapSync(this.sessionMap); + if (enterpriseSessionMapStoreEnabled()) { + try { + deleteClaudeSessionMapRuntimeSnapshots(sessionId); + } catch (err) { + console.warn('[ClaudeRuntime] Failed to delete runtime_snapshots session map:', (err as Error).message); + } + } else { + // Use immediate save — session is being removed, must persist before cleanup completes + savePersistedSessionMapSync(this.sessionMap); + } } /** Clean up all session-scoped state for a given session. */ @@ -2270,16 +2373,13 @@ export class ClaudeRuntime extends EventEmitter implements IOrchestrator { const sessionContext = precomputed?.sessionContext ?? sessionContextManager.getOrCreate(sessionId, traceId); const previousTurns = precomputed?.previousTurns ?? (sessionContext.getAllTurns?.() || []); // Composite key for comparison mode session identity isolation - const sessionMapKey = referenceTraceId - ? `${sessionId}:ref:${referenceTraceId}` - : sessionId; + const sessionMapKey = this.buildSessionMapKey(sessionId, referenceTraceId); const sessionMapEntry = this.sessionMap.get(sessionMapKey); const existingSdkSession = sessionMapEntry?.sdkSessionId; // P0-3: SDK sessions on Anthropic's side expire after ~4 hours. // If the local sessionMap entry is stale, treat it as expired and inject full manual context. // Without this check, `hasActiveResume` stays true for stale entries, causing the system // to skip both SDK context (expired) AND manual context injection → silent context loss. - const SDK_SESSION_FRESHNESS_MS = 4 * 60 * 60 * 1000; // 4 hours const sdkSessionFresh = !!sessionMapEntry && (Date.now() - (sessionMapEntry.updatedAt || 0) < SDK_SESSION_FRESHNESS_MS); const hasActiveResume = !!existingSdkSession && sdkSessionFresh; const previousFindings = hasActiveResume diff --git a/backend/src/routes/agentRoutes.ts b/backend/src/routes/agentRoutes.ts index 46e340cb..7226eb37 100644 --- a/backend/src/routes/agentRoutes.ts +++ b/backend/src/routes/agentRoutes.ts @@ -2452,7 +2452,12 @@ async function runAgentDrivenAnalysis( selectionContext: options.selectionContext, analysisMode: options.analysisMode, traceContext: options.traceContext, + referenceTraceId: options.referenceTraceId, providerId: options.providerId, + tenantId: session.tenantId, + workspaceId: session.workspaceId, + userId: session.userId, + runId: session.activeRun?.runId, }); }); console.log('[AgentRoutes.AgentDriven] analyze completed, success:', result.success); diff --git a/backend/src/services/__tests__/runtimeSnapshotStore.test.ts b/backend/src/services/__tests__/runtimeSnapshotStore.test.ts new file mode 100644 index 00000000..d012ce44 --- /dev/null +++ b/backend/src/services/__tests__/runtimeSnapshotStore.test.ts @@ -0,0 +1,178 @@ +// 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/promises'; +import os from 'os'; +import path from 'path'; +import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../enterpriseDb'; +import { + CLAUDE_SESSION_MAP_RUNTIME_TYPE, + deleteClaudeSessionMapRuntimeSnapshots, + loadClaudeSessionMapFromRuntimeSnapshots, + saveClaudeSessionMapToRuntimeSnapshots, +} from '../runtimeSnapshotStore'; + +const originalDbPath = process.env[ENTERPRISE_DB_PATH_ENV]; + +interface RuntimeSnapshotRow { + id: string; + tenant_id: string; + workspace_id: string; + session_id: string; + run_id: string; + runtime_type: string; + snapshot_json: string; + created_at: number; +} + +let tmpDir: string | undefined; +let dbPath: string; + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function readRuntimeSnapshots(): RuntimeSnapshotRow[] { + const db = openEnterpriseDb(dbPath); + try { + return db.prepare(` + SELECT * + FROM runtime_snapshots + ORDER BY id + `).all(); + } finally { + db.close(); + } +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-runtime-snapshot-store-')); + dbPath = path.join(tmpDir, 'enterprise.sqlite'); + process.env[ENTERPRISE_DB_PATH_ENV] = dbPath; +}); + +afterEach(async () => { + restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalDbPath); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } +}); + +describe('runtime snapshot store', () => { + it('stores Claude session maps in runtime_snapshots with enterprise graph rows', () => { + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'user-a', + sessionId: 'session-a', + runId: 'run-a', + traceId: 'trace-a', + }, 'session-a', { + sdkSessionId: 'sdk-session-a', + updatedAt: 1_700_000_000_000, + }); + + const rows = readRuntimeSnapshots(); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual(expect.objectContaining({ + tenant_id: 'tenant-a', + workspace_id: 'workspace-a', + session_id: 'session-a', + run_id: 'run-a', + runtime_type: CLAUDE_SESSION_MAP_RUNTIME_TYPE, + created_at: 1_700_000_000_000, + })); + expect(JSON.parse(rows[0].snapshot_json)).toEqual({ + sessionMapKey: 'session-a', + sdkSessionId: 'sdk-session-a', + updatedAt: 1_700_000_000_000, + traceId: 'trace-a', + }); + + const db = openEnterpriseDb(dbPath); + try { + expect(db.prepare('SELECT COUNT(*) AS count FROM organizations').get()).toEqual({ count: 1 }); + expect(db.prepare('SELECT COUNT(*) AS count FROM analysis_sessions').get()).toEqual({ count: 1 }); + expect(db.prepare('SELECT COUNT(*) AS count FROM analysis_runs').get()).toEqual({ count: 1 }); + } finally { + db.close(); + } + }); + + it('loads the latest non-stale entry per session map key', () => { + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + sessionId: 'session-a', + runId: 'run-a', + traceId: 'trace-a', + }, 'session-a', { + sdkSessionId: 'old-sdk', + updatedAt: 1_700_000_000_000, + }); + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + sessionId: 'session-a', + runId: 'run-a', + traceId: 'trace-a', + }, 'session-a', { + sdkSessionId: 'new-sdk', + updatedAt: 1_700_000_010_000, + }); + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + sessionId: 'session-stale', + runId: 'run-stale', + traceId: 'trace-stale', + }, 'session-stale', { + sdkSessionId: 'stale-sdk', + updatedAt: 1_699_000_000_000, + }); + + const map = loadClaudeSessionMapFromRuntimeSnapshots( + 60_000, + 1_700_000_020_000, + ); + + expect(map.get('session-a')).toEqual({ + sdkSessionId: 'new-sdk', + updatedAt: 1_700_000_010_000, + }); + expect(map.has('session-stale')).toBe(false); + expect(readRuntimeSnapshots()).toHaveLength(2); + }); + + it('deletes all Claude session map rows for a SmartPerfetto session', () => { + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + sessionId: 'session-a', + runId: 'run-a', + traceId: 'trace-a', + }, 'session-a', { + sdkSessionId: 'sdk-a', + updatedAt: 1_700_000_000_000, + }); + saveClaudeSessionMapToRuntimeSnapshots({ + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + sessionId: 'session-a', + runId: 'run-a', + traceId: 'trace-a', + }, 'session-a:ref:trace-b', { + sdkSessionId: 'sdk-b', + updatedAt: 1_700_000_000_100, + }); + + expect(deleteClaudeSessionMapRuntimeSnapshots('session-a')).toBe(2); + expect(readRuntimeSnapshots()).toHaveLength(0); + }); +}); diff --git a/backend/src/services/enterpriseRepository.ts b/backend/src/services/enterpriseRepository.ts index 63dc6ed4..e409d350 100644 --- a/backend/src/services/enterpriseRepository.ts +++ b/backend/src/services/enterpriseRepository.ts @@ -17,13 +17,15 @@ export type EnterpriseWorkspaceScopedTable = | 'trace_assets' | 'analysis_sessions' | 'analysis_runs' - | 'agent_events'; + | 'agent_events' + | 'runtime_snapshots'; export const ENTERPRISE_WORKSPACE_SCOPED_TABLES: readonly EnterpriseWorkspaceScopedTable[] = [ 'trace_assets', 'analysis_sessions', 'analysis_runs', 'agent_events', + 'runtime_snapshots', ]; export type EnterpriseQueryCriteria = Record; diff --git a/backend/src/services/runtimeSnapshotStore.ts b/backend/src/services/runtimeSnapshotStore.ts new file mode 100644 index 00000000..c9732054 --- /dev/null +++ b/backend/src/services/runtimeSnapshotStore.ts @@ -0,0 +1,273 @@ +// 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 { openEnterpriseDb } from './enterpriseDb'; +import { createEnterpriseWorkspaceRepository } from './enterpriseRepository'; + +export const CLAUDE_SESSION_MAP_RUNTIME_TYPE = 'claude-session-map'; + +const SAFE_RUNTIME_ID_RE = /^[a-zA-Z0-9._:-]+$/; + +export interface ClaudeSessionMapRuntimeEntry { + sdkSessionId: string; + updatedAt: number; +} + +export interface RuntimeSnapshotScope { + tenantId: string; + workspaceId: string; + userId?: string; + sessionId: string; + runId?: string; + traceId?: string; +} + +interface RuntimeSnapshotRow extends Record { + id: string; + tenant_id: string; + workspace_id: string; + session_id: string; + run_id: string; + runtime_type: string; + snapshot_json: string; + created_at: number; +} + +interface ClaudeSessionMapSnapshotJson { + sessionMapKey?: unknown; + sdkSessionId?: unknown; + updatedAt?: unknown; + traceId?: unknown; +} + +function assertSafeRuntimeId(value: string, label: string): string { + if (!SAFE_RUNTIME_ID_RE.test(value) || value === '.' || value === '..') { + throw new Error(`Unsafe ${label}: ${value}`); + } + return value; +} + +function fallbackTraceId(scope: RuntimeSnapshotScope): string { + return scope.traceId || `trace-${scope.sessionId}-runtime-snapshot`; +} + +function fallbackRunId(scope: RuntimeSnapshotScope): string { + return scope.runId || `run-${scope.sessionId}-runtime-snapshot`; +} + +function runtimeSnapshotId(tenantId: string, workspaceId: string, sessionMapKey: string): string { + const digest = crypto + .createHash('sha256') + .update(`${tenantId}\0${workspaceId}\0${sessionMapKey}`) + .digest('hex') + .slice(0, 32); + return `claude-session-map-${digest}`; +} + +function withRuntimeSnapshotDb(fn: (db: Database.Database) => T): T { + const db = openEnterpriseDb(); + try { + return fn(db); + } finally { + db.close(); + } +} + +function parseSnapshotJson(row: RuntimeSnapshotRow): [string, ClaudeSessionMapRuntimeEntry] | null { + try { + const parsed = JSON.parse(row.snapshot_json) as ClaudeSessionMapSnapshotJson; + if (typeof parsed.sessionMapKey !== 'string' || typeof parsed.sdkSessionId !== 'string') { + return null; + } + const updatedAt = typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt) + ? parsed.updatedAt + : row.created_at; + return [parsed.sessionMapKey, { sdkSessionId: parsed.sdkSessionId, updatedAt }]; + } catch { + return null; + } +} + +function ensureRuntimeSnapshotGraph( + db: Database.Database, + scope: RuntimeSnapshotScope, +): { tenantId: string; workspaceId: string; userId: string | null; sessionId: string; traceId: string; runId: string } { + const tenantId = assertSafeRuntimeId(scope.tenantId, 'tenant id'); + const workspaceId = assertSafeRuntimeId(scope.workspaceId, 'workspace id'); + const sessionId = assertSafeRuntimeId(scope.sessionId, 'session id'); + const userId = scope.userId ? assertSafeRuntimeId(scope.userId, 'user id') : null; + const traceId = assertSafeRuntimeId(fallbackTraceId(scope), 'trace id'); + const runId = assertSafeRuntimeId(fallbackRunId(scope), 'run id'); + const now = Date.now(); + + db.prepare(` + INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES (?, ?, 'active', 'enterprise', ?, ?) + `).run(tenantId, tenantId, now, now); + db.prepare(` + INSERT OR IGNORE INTO workspaces (id, tenant_id, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run(workspaceId, tenantId, workspaceId, now, now); + if (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( + userId, + tenantId, + `${userId}@runtime.local`, + userId, + `runtime:${userId}`, + now, + now, + ); + } + db.prepare(` + INSERT OR IGNORE INTO trace_assets + (id, tenant_id, workspace_id, owner_user_id, local_path, size_bytes, status, metadata_json, created_at) + VALUES + (?, ?, ?, ?, ?, 0, 'metadata_only', ?, ?) + `).run( + traceId, + tenantId, + workspaceId, + userId, + `metadata-only:${traceId}`, + JSON.stringify({ source: 'runtime_snapshot', sessionId }), + now, + ); + db.prepare(` + INSERT OR IGNORE INTO analysis_sessions + (id, tenant_id, workspace_id, trace_id, created_by, title, visibility, status, created_at, updated_at) + VALUES + (?, ?, ?, ?, ?, ?, 'private', 'running', ?, ?) + `).run( + sessionId, + tenantId, + workspaceId, + traceId, + userId, + `Runtime ${sessionId}`, + now, + now, + ); + db.prepare(` + INSERT OR IGNORE INTO analysis_runs + (id, tenant_id, workspace_id, session_id, mode, status, question, started_at, completed_at) + VALUES + (?, ?, ?, ?, 'agent', 'running', '', ?, NULL) + `).run( + runId, + tenantId, + workspaceId, + sessionId, + now, + ); + + return { tenantId, workspaceId, userId, sessionId, traceId, runId }; +} + +export function loadClaudeSessionMapFromRuntimeSnapshots( + maxAgeMs: number, + now: number = Date.now(), +): Map { + return withRuntimeSnapshotDb((db) => { + const rows = db.prepare(` + SELECT * + FROM runtime_snapshots + WHERE runtime_type = ? + ORDER BY created_at ASC + `).all(CLAUDE_SESSION_MAP_RUNTIME_TYPE); + + const map = new Map(); + for (const row of rows) { + const parsed = parseSnapshotJson(row); + if (!parsed) continue; + const [sessionMapKey, entry] = parsed; + if (now - entry.updatedAt > maxAgeMs) continue; + map.set(sessionMapKey, entry); + } + return map; + }); +} + +export function saveClaudeSessionMapToRuntimeSnapshots( + scope: RuntimeSnapshotScope, + sessionMapKey: string, + entry: ClaudeSessionMapRuntimeEntry, +): void { + withRuntimeSnapshotDb((db) => { + const write = db.transaction(() => { + const graph = ensureRuntimeSnapshotGraph(db, scope); + const id = runtimeSnapshotId(graph.tenantId, graph.workspaceId, sessionMapKey); + const snapshotJson = JSON.stringify({ + sessionMapKey, + sdkSessionId: entry.sdkSessionId, + updatedAt: entry.updatedAt, + traceId: graph.traceId, + }); + + db.prepare(` + INSERT INTO runtime_snapshots + (id, tenant_id, workspace_id, session_id, run_id, runtime_type, snapshot_json, created_at) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + tenant_id = excluded.tenant_id, + workspace_id = excluded.workspace_id, + session_id = excluded.session_id, + run_id = excluded.run_id, + runtime_type = excluded.runtime_type, + snapshot_json = excluded.snapshot_json, + created_at = excluded.created_at + `).run( + id, + graph.tenantId, + graph.workspaceId, + graph.sessionId, + graph.runId, + CLAUDE_SESSION_MAP_RUNTIME_TYPE, + snapshotJson, + entry.updatedAt, + ); + }); + write(); + }); +} + +export function deleteClaudeSessionMapRuntimeSnapshots(sessionId: string): number { + const safeSessionId = assertSafeRuntimeId(sessionId, 'session id'); + return withRuntimeSnapshotDb((db) => { + const rows = db.prepare(` + SELECT * + FROM runtime_snapshots + WHERE runtime_type = ? AND session_id = ? + `).all(CLAUDE_SESSION_MAP_RUNTIME_TYPE, safeSessionId); + let deleted = 0; + const byScope = new Map(); + for (const row of rows) { + const key = `${row.tenant_id}\0${row.workspace_id}`; + const scopedRows = byScope.get(key) ?? []; + scopedRows.push(row); + byScope.set(key, scopedRows); + } + for (const scopedRows of byScope.values()) { + const [first] = scopedRows; + const repo = createEnterpriseWorkspaceRepository(db, 'runtime_snapshots'); + for (const row of scopedRows) { + deleted += repo.deleteById({ + tenantId: first.tenant_id, + workspaceId: first.workspace_id, + }, row.id); + } + } + return deleted; + }); +} diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index 2540b127..1583518e 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -43,7 +43,7 @@ - [x] 3.2 实现 §10.2 全部核心表 + 索引 + migration(含 audit / tombstone) - [x] 3.3 trace metadata 入 DB;trace 文件迁到 `data/{tenantId}/{workspaceId}/traces/` - [x] 3.4 report metadata 入 DB;report 内容迁到 `data/{tenantId}/{workspaceId}/reports/`(§14.2) -- [ ] 3.5 `logs/claude_session_map.json` 迁到 `runtime_snapshots` +- [x] 3.5 `logs/claude_session_map.json` 迁到 `runtime_snapshots` - [ ] 3.6 provider 从 `data/providers.json` 迁到 DB metadata + encrypted SecretStore - [ ] 3.7 Memory / RAG / Case / Baseline 表加 scope(§14.1,先 filter 后语义召回) - [ ] 3.8 双写 → 切读 → 退役 三阶段(§17),每阶段都能回滚;准备 filesystem + DB snapshot