diff --git a/backend/package.json b/backend/package.json index f556a649..d6376ad3 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", + "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:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest --testPathPatterns=src/tests", diff --git a/backend/src/routes/__tests__/enterpriseReportRoutes.test.ts b/backend/src/routes/__tests__/enterpriseReportRoutes.test.ts new file mode 100644 index 00000000..ac85be4f --- /dev/null +++ b/backend/src/routes/__tests__/enterpriseReportRoutes.test.ts @@ -0,0 +1,182 @@ +// 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 { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import express from 'express'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; +import { ENTERPRISE_FEATURE_FLAG_ENV } from '../../config'; +import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../../services/enterpriseDb'; +import { ENTERPRISE_DATA_DIR_ENV } from '../../services/traceMetadataStore'; +import reportRoutes, { persistReport, reportStore } from '../reportRoutes'; + +const originalEnv = { + enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV], + trustedHeaders: process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS, + enterpriseDbPath: process.env[ENTERPRISE_DB_PATH_ENV], + enterpriseDataDir: process.env[ENTERPRISE_DATA_DIR_ENV], + apiKey: process.env.SMARTPERFETTO_API_KEY, +}; + +interface ReportArtifactRow { + id: string; + tenant_id: string; + workspace_id: string; + session_id: string; + run_id: string; + local_path: string; + content_hash: string; + visibility: string; + created_by: string | null; +} + +let tmpDir: string; +let dbPath: string; +let dataDir: string; + +function makeApp(): express.Express { + const app = express(); + app.use(express.json()); + app.use('/api/reports', reportRoutes); + return app; +} + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function ssoHeaders(req: request.Test, workspaceId = 'workspace-a'): request.Test { + return req + .set('X-SmartPerfetto-SSO-User-Id', 'user-a') + .set('X-SmartPerfetto-SSO-Email', 'user-a@example.test') + .set('X-SmartPerfetto-SSO-Tenant-Id', 'tenant-a') + .set('X-SmartPerfetto-SSO-Workspace-Id', workspaceId) + .set('X-SmartPerfetto-SSO-Roles', 'workspace_admin') + .set('X-SmartPerfetto-SSO-Scopes', 'report:read,report:delete'); +} + +function readReportArtifact(reportId: string): ReportArtifactRow | null { + const db = openEnterpriseDb(dbPath); + try { + return db.prepare(` + SELECT * + FROM report_artifacts + WHERE id = ? + `).get(reportId) || null; + } finally { + db.close(); + } +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-report-routes-')); + dbPath = path.join(tmpDir, 'enterprise.sqlite'); + dataDir = path.join(tmpDir, 'data'); + + process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true'; + process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true'; + process.env[ENTERPRISE_DB_PATH_ENV] = dbPath; + process.env[ENTERPRISE_DATA_DIR_ENV] = dataDir; + delete process.env.SMARTPERFETTO_API_KEY; + reportStore.clear(); +}); + +afterEach(async () => { + reportStore.clear(); + restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise); + restoreEnvValue('SMARTPERFETTO_SSO_TRUSTED_HEADERS', originalEnv.trustedHeaders); + restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalEnv.enterpriseDbPath); + restoreEnvValue(ENTERPRISE_DATA_DIR_ENV, originalEnv.enterpriseDataDir); + restoreEnvValue('SMARTPERFETTO_API_KEY', originalEnv.apiKey); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('enterprise report routes', () => { + it('stores reports in report_artifacts and reloads them from scoped data storage', async () => { + const app = makeApp(); + const reportId = 'report-a'; + + persistReport(reportId, { + html: 'enterprise report', + generatedAt: 1_700_000_000_000, + sessionId: 'session-a', + runId: 'run-a', + traceId: 'trace-a', + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'user-a', + visibility: 'private', + }); + + const expectedHtmlPath = path.join( + dataDir, + 'tenant-a', + 'workspace-a', + 'reports', + reportId, + 'report.html', + ); + const expectedJsonPath = path.join(path.dirname(expectedHtmlPath), 'report.json'); + await expect(fs.access(expectedHtmlPath)).resolves.toBeUndefined(); + await expect(fs.access(expectedJsonPath)).resolves.toBeUndefined(); + + const row = readReportArtifact(reportId); + expect(row).toEqual(expect.objectContaining({ + id: reportId, + tenant_id: 'tenant-a', + workspace_id: 'workspace-a', + session_id: 'session-a', + run_id: 'run-a', + local_path: expectedHtmlPath, + visibility: 'private', + created_by: 'user-a', + })); + expect(row!.content_hash).toHaveLength(64); + + reportStore.clear(); + const getRes = await ssoHeaders(request(app).get(`/api/reports/${reportId}`)); + expect(getRes.status).toBe(200); + expect(getRes.text).toContain('enterprise report'); + + const otherWorkspaceRes = await ssoHeaders( + request(app).get(`/api/reports/${reportId}`), + 'workspace-b', + ); + expect(otherWorkspaceRes.status).toBe(404); + }); + + it('deletes enterprise report_artifacts metadata and scoped report files', async () => { + const app = makeApp(); + const reportId = 'report-delete'; + + persistReport(reportId, { + html: 'delete report', + generatedAt: 1_700_000_000_000, + sessionId: 'session-delete', + runId: 'run-delete', + traceId: 'trace-delete', + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + userId: 'user-a', + visibility: 'private', + }); + const row = readReportArtifact(reportId); + expect(row).not.toBeNull(); + + reportStore.clear(); + const deleteRes = await ssoHeaders(request(app).delete(`/api/reports/${reportId}`)); + + expect(deleteRes.status).toBe(200); + expect(deleteRes.body.success).toBe(true); + expect(readReportArtifact(reportId)).toBeNull(); + await expect(fs.access(row!.local_path)).rejects.toThrow(); + await expect(fs.access(path.dirname(row!.local_path))).rejects.toThrow(); + }); +}); diff --git a/backend/src/routes/agentRoutes.ts b/backend/src/routes/agentRoutes.ts index efba31cf..46e340cb 100644 --- a/backend/src/routes/agentRoutes.ts +++ b/backend/src/routes/agentRoutes.ts @@ -4068,9 +4068,12 @@ function sendAgentDrivenResult(res: express.Response, session: AnalysisSession) html, generatedAt: Date.now(), sessionId: session.sessionId, + runId: session.lastRun?.runId || session.activeRun?.runId, + traceId: session.traceId, tenantId: session.tenantId, workspaceId: session.workspaceId, userId: session.userId, + visibility: 'private', }); reportUrl = `/api/reports/${reportId}`; diff --git a/backend/src/routes/reportRoutes.ts b/backend/src/routes/reportRoutes.ts index d31c9f7b..fbb933ff 100644 --- a/backend/src/routes/reportRoutes.ts +++ b/backend/src/routes/reportRoutes.ts @@ -10,11 +10,16 @@ */ import express from 'express'; +import crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; +import type Database from 'better-sqlite3'; import { attachRequestContext, requireRequestContext } from '../middleware/auth'; +import { resolveFeatureConfig } from '../config'; +import { openEnterpriseDb } from '../services/enterpriseDb'; import { REPORT_CAUSAL_MAP_CSS, REPORT_CAUSAL_MAP_SCRIPT } from '../services/reportCausalMapAssets'; import { localize, parseOutputLanguage } from '../agentv3/outputLanguage'; +import { resolveEnterpriseDataRoot } from '../services/traceMetadataStore'; import { sendResourceNotFound, type ResourceOwnerFields, @@ -42,10 +47,247 @@ type PersistedReport = ResourceOwnerFields & { html: string; generatedAt: number; sessionId: string; + runId?: string; + traceId?: string; + visibility?: string; }; export const reportStore = new Map(); +interface ReportArtifactRow { + id: string; + tenant_id: string; + workspace_id: string; + session_id: string; + run_id: string; + local_path: string; + content_hash: string | null; + visibility: string; + created_by: string | null; + created_at: number; + expires_at: number | null; +} + +const SAFE_REPORT_ID_RE = /^[a-zA-Z0-9._:-]+$/; + +function enterpriseReportStoreEnabled(): boolean { + return resolveFeatureConfig(process.env).enterprise; +} + +function assertSafeReportSegment(value: string, label: string): string { + if (!SAFE_REPORT_ID_RE.test(value) || value === '.' || value === '..') { + throw new Error(`Unsafe ${label}: ${value}`); + } + return value; +} + +function reportContentHash(html: string): string { + return crypto.createHash('sha256').update(html).digest('hex'); +} + +function withEnterpriseReportDb(fn: (db: Database.Database) => T): T { + const db = openEnterpriseDb(); + try { + return fn(db); + } finally { + db.close(); + } +} + +function enterpriseReportDir(reportId: string, entry: PersistedReport): string { + if (!entry.tenantId || !entry.workspaceId) { + throw new Error('Enterprise report persistence requires tenantId and workspaceId'); + } + return path.join( + resolveEnterpriseDataRoot(), + assertSafeReportSegment(entry.tenantId, 'tenant id'), + assertSafeReportSegment(entry.workspaceId, 'workspace id'), + 'reports', + assertSafeReportSegment(reportId, 'report id'), + ); +} + +function fallbackTraceId(entry: PersistedReport): string { + return entry.traceId || `trace-${entry.sessionId}-report`; +} + +function fallbackRunId(entry: PersistedReport): string { + return entry.runId || `run-${entry.sessionId}-report`; +} + +function ensureEnterpriseReportGraph( + db: Database.Database, + reportId: string, + entry: PersistedReport, +): { traceId: string; runId: string } { + if (!entry.tenantId || !entry.workspaceId) { + throw new Error('Enterprise report persistence requires tenantId and workspaceId'); + } + const tenantId = assertSafeReportSegment(entry.tenantId, 'tenant id'); + const workspaceId = assertSafeReportSegment(entry.workspaceId, 'workspace id'); + const userId = entry.userId ? assertSafeReportSegment(entry.userId, 'user id') : null; + const traceId = fallbackTraceId(entry); + const runId = fallbackRunId(entry); + 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}@report.local`, + userId, + `report:${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: 'report_artifact', reportId }), + entry.generatedAt || 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 + (?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?) + `).run( + entry.sessionId, + tenantId, + workspaceId, + traceId, + userId, + `Report ${reportId}`, + entry.visibility || 'private', + entry.generatedAt || 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 + (?, ?, ?, ?, 'report', 'completed', '', ?, ?) + `).run( + runId, + tenantId, + workspaceId, + entry.sessionId, + entry.generatedAt || now, + entry.generatedAt || now, + ); + + return { traceId, runId }; +} + +function persistEnterpriseReport(reportId: string, entry: PersistedReport): void { + const reportDir = enterpriseReportDir(reportId, entry); + const htmlPath = path.join(reportDir, 'report.html'); + const metadataPath = path.join(reportDir, 'report.json'); + fs.mkdirSync(reportDir, { recursive: true }); + fs.writeFileSync(htmlPath, entry.html, 'utf-8'); + + withEnterpriseReportDb((db) => { + const { runId } = ensureEnterpriseReportGraph(db, reportId, entry); + const createdAt = entry.generatedAt || Date.now(); + const visibility = entry.visibility || 'private'; + const contentHash = reportContentHash(entry.html); + db.prepare(` + INSERT INTO report_artifacts + (id, tenant_id, workspace_id, session_id, run_id, local_path, content_hash, visibility, created_by, created_at, expires_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, + local_path = excluded.local_path, + content_hash = excluded.content_hash, + visibility = excluded.visibility, + created_by = excluded.created_by, + expires_at = excluded.expires_at + `).run( + reportId, + entry.tenantId, + entry.workspaceId, + entry.sessionId, + runId, + htmlPath, + contentHash, + visibility, + entry.userId ?? null, + createdAt, + null, + ); + + fs.writeFileSync(metadataPath, JSON.stringify({ + reportId, + generatedAt: createdAt, + sessionId: entry.sessionId, + runId, + traceId: fallbackTraceId(entry), + tenantId: entry.tenantId, + workspaceId: entry.workspaceId, + userId: entry.userId, + visibility, + contentHash, + }, null, 2)); + }); +} + +function loadEnterpriseReport(reportId: string): PersistedReport | null { + if (!SAFE_REPORT_ID_RE.test(reportId)) return null; + try { + return withEnterpriseReportDb((db) => { + const row = db.prepare(` + SELECT * + FROM report_artifacts + WHERE id = ? + `).get(reportId); + if (!row || !fs.existsSync(row.local_path)) return null; + const html = fs.readFileSync(row.local_path, 'utf-8'); + const entry: PersistedReport = { + html: upgradeLegacyReportHtml(html), + generatedAt: row.created_at, + sessionId: row.session_id, + runId: row.run_id, + tenantId: row.tenant_id, + workspaceId: row.workspace_id, + ...(row.created_by ? { userId: row.created_by } : {}), + visibility: row.visibility, + }; + reportStore.set(reportId, entry); + return entry; + }); + } catch { + return null; + } +} + const LEGACY_MERMAID_UPGRADE_CSS = REPORT_CAUSAL_MAP_CSS; const LEGACY_MERMAID_UPGRADE_SCRIPT = REPORT_CAUSAL_MAP_SCRIPT; @@ -74,6 +316,10 @@ export function upgradeLegacyReportHtml(html: string): string { export function persistReport(reportId: string, entry: PersistedReport): void { reportStore.set(reportId, entry); try { + if (enterpriseReportStoreEnabled()) { + persistEnterpriseReport(reportId, entry); + return; + } const filePath = path.join(REPORTS_DIR, `${reportId}.html`); fs.writeFileSync(filePath, entry.html, 'utf-8'); // Write metadata alongside @@ -81,9 +327,12 @@ export function persistReport(reportId: string, entry: PersistedReport): void { fs.writeFileSync(metaPath, JSON.stringify({ generatedAt: entry.generatedAt, sessionId: entry.sessionId, + runId: entry.runId, + traceId: entry.traceId, tenantId: entry.tenantId, workspaceId: entry.workspaceId, userId: entry.userId, + visibility: entry.visibility, })); } catch (err) { console.warn('[ReportRoutes] Failed to persist report to disk:', (err as Error).message); @@ -92,6 +341,9 @@ export function persistReport(reportId: string, entry: PersistedReport): void { /** Load a report from disk if not in memory cache. */ function loadReportFromDisk(reportId: string): PersistedReport | null { + if (enterpriseReportStoreEnabled()) { + return loadEnterpriseReport(reportId); + } try { const filePath = path.join(REPORTS_DIR, `${reportId}.html`); if (!fs.existsSync(filePath)) return null; @@ -100,11 +352,17 @@ function loadReportFromDisk(reportId: string): PersistedReport | null { const metaPath = path.join(REPORTS_DIR, `${reportId}.meta.json`); let generatedAt = Date.now(); let sessionId = ''; + let runId: string | undefined; + let traceId: string | undefined; + let visibility: string | undefined; let owner: ResourceOwnerFields = {}; if (fs.existsSync(metaPath)) { const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); generatedAt = meta.generatedAt || generatedAt; sessionId = meta.sessionId || ''; + runId = meta.runId; + traceId = meta.traceId; + visibility = meta.visibility; owner = { tenantId: meta.tenantId, workspaceId: meta.workspaceId, @@ -113,7 +371,15 @@ function loadReportFromDisk(reportId: string): PersistedReport | null { }; } - const entry = { html: upgradeLegacyReportHtml(html), generatedAt, sessionId, ...owner }; + const entry = { + html: upgradeLegacyReportHtml(html), + generatedAt, + sessionId, + ...(runId ? { runId } : {}), + ...(traceId ? { traceId } : {}), + ...(visibility ? { visibility } : {}), + ...owner, + }; // Cache in memory for subsequent access reportStore.set(reportId, entry); return entry; @@ -122,6 +388,42 @@ function loadReportFromDisk(reportId: string): PersistedReport | null { } } +function deletePersistedReport(reportId: string): boolean { + if (enterpriseReportStoreEnabled()) { + if (!SAFE_REPORT_ID_RE.test(reportId)) return false; + try { + return withEnterpriseReportDb((db) => { + const row = db.prepare( + 'SELECT * FROM report_artifacts WHERE id = ?', + ).get(reportId); + if (!row) return false; + db.prepare('DELETE FROM report_artifacts WHERE id = ?').run(reportId); + try { + const reportDir = path.dirname(row.local_path); + const metadataPath = path.join(reportDir, 'report.json'); + if (fs.existsSync(row.local_path)) fs.unlinkSync(row.local_path); + if (fs.existsSync(metadataPath)) fs.unlinkSync(metadataPath); + fs.rmSync(reportDir, { recursive: true, force: true }); + } catch { /* non-fatal */ } + return true; + }); + } catch { + return false; + } + } + + try { + const htmlPath = path.join(REPORTS_DIR, `${reportId}.html`); + const metaPath = path.join(REPORTS_DIR, `${reportId}.meta.json`); + const existed = fs.existsSync(htmlPath) || fs.existsSync(metaPath); + if (fs.existsSync(htmlPath)) fs.unlinkSync(htmlPath); + if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath); + return existed; + } catch { + return false; + } +} + function getReportForContext(reportId: string, req: express.Request): PersistedReport | null { const context = requireRequestContext(req); const report = reportStore.get(reportId) || loadReportFromDisk(reportId); @@ -268,15 +570,9 @@ router.delete('/:reportId', (req, res) => { return sendForbidden(res, 'Deleting this report requires report delete permission'); } - const deleted = reportStore.delete(reportId); - - // Also clean disk files - try { - const htmlPath = path.join(REPORTS_DIR, `${reportId}.html`); - const metaPath = path.join(REPORTS_DIR, `${reportId}.meta.json`); - if (fs.existsSync(htmlPath)) fs.unlinkSync(htmlPath); - if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath); - } catch { /* non-fatal */ } + const deletedFromCache = reportStore.delete(reportId); + const deletedFromPersistence = deletePersistedReport(reportId); + const deleted = deletedFromCache || deletedFromPersistence; res.json({ success: deleted, diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index 702bdbe8..2540b127 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -42,7 +42,7 @@ - [x] 3.1 选 SQLite WAL 还是单 Postgres,落地 ADR;建立 repository 抽象(默认追加 tenantId/workspaceId filter) - [x] 3.2 实现 §10.2 全部核心表 + 索引 + migration(含 audit / tombstone) - [x] 3.3 trace metadata 入 DB;trace 文件迁到 `data/{tenantId}/{workspaceId}/traces/` -- [ ] 3.4 report metadata 入 DB;report 内容迁到 `data/{tenantId}/{workspaceId}/reports/`(§14.2) +- [x] 3.4 report metadata 入 DB;report 内容迁到 `data/{tenantId}/{workspaceId}/reports/`(§14.2) - [ ] 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 后语义召回)