Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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__/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__/enterpriseMigration.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/localSecretStore.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__/enterpriseMigration.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/localSecretStore.test.ts src/services/providerManager/__tests__/enterpriseProviderStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts src/routes/__tests__/enterpriseRestartPersistence.test.ts",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:unit": "jest --testPathPatterns=src/tests",
Expand Down
237 changes: 237 additions & 0 deletions backend/src/routes/__tests__/enterpriseRestartPersistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// 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 { EnhancedSessionContext, sessionContextManager } from '../../agent/context/enhancedSessionContext';
import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../../services/enterpriseDb';
import { ENTERPRISE_MIGRATION_PHASE_ENV } from '../../services/enterpriseMigration';
import { SessionPersistenceService } from '../../services/sessionPersistenceService';
import {
ENTERPRISE_DATA_DIR_ENV,
writeTraceMetadata,
} from '../../services/traceMetadataStore';
import agentRoutes from '../agentRoutes';
import reportRoutes, { persistReport, reportStore } from '../reportRoutes';
import traceRoutes from '../simpleTraceRoutes';

const originalEnv = {
enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV],
migrationPhase: process.env[ENTERPRISE_MIGRATION_PHASE_ENV],
trustedHeaders: process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS,
enterpriseDbPath: process.env[ENTERPRISE_DB_PATH_ENV],
enterpriseDataDir: process.env[ENTERPRISE_DATA_DIR_ENV],
uploadDir: process.env.UPLOAD_DIR,
apiKey: process.env.SMARTPERFETTO_API_KEY,
};

const TENANT_ID = 'tenant-a';
const WORKSPACE_ID = 'workspace-a';
const USER_ID = 'user-a';
const TRACE_ID = 'trace-restart-a';
const SESSION_ID = 'session-restart-a';
const RUN_ID = 'run-restart-a';
const REPORT_ID = 'report-restart-a';
const GENERATED_AT = 1_700_000_000_000;

let tmpDir: string;
let dbPath: string;
let dataDir: string;
let uploadDir: string;

function restoreEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}

function makeApp(): express.Express {
const app = express();
app.use(express.json());
app.use('/api/traces', traceRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/agent/v1', agentRoutes);
return app;
}

function ssoHeaders(req: request.Test, workspaceId = WORKSPACE_ID): request.Test {
return req
.set('X-SmartPerfetto-SSO-User-Id', USER_ID)
.set('X-SmartPerfetto-SSO-Email', `${USER_ID}@example.test`)
.set('X-SmartPerfetto-SSO-Tenant-Id', TENANT_ID)
.set('X-SmartPerfetto-SSO-Workspace-Id', workspaceId)
.set('X-SmartPerfetto-SSO-Roles', 'workspace_admin')
.set(
'X-SmartPerfetto-SSO-Scopes',
'trace:read,trace:write,trace:download,report:read,report:delete,agent:run',
);
}

function readCount(table: 'trace_assets' | 'report_artifacts' | 'sessions'): number {
const db = openEnterpriseDb(dbPath);
try {
const row = db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
return row.count;
} finally {
db.close();
}
}

async function seedRestartState(): Promise<void> {
const tracePath = path.join(dataDir, TENANT_ID, WORKSPACE_ID, 'traces', `${TRACE_ID}.trace`);
await fs.mkdir(path.dirname(tracePath), { recursive: true });
await fs.writeFile(tracePath, 'trace bytes', 'utf-8');

await writeTraceMetadata({
id: TRACE_ID,
filename: 'restart.trace',
size: Buffer.byteLength('trace bytes'),
uploadedAt: new Date(GENERATED_AT).toISOString(),
status: 'ready',
path: tracePath,
tenantId: TENANT_ID,
workspaceId: WORKSPACE_ID,
userId: USER_ID,
});

persistReport(REPORT_ID, {
html: '<html><body>restart report</body></html>',
generatedAt: GENERATED_AT,
sessionId: SESSION_ID,
runId: RUN_ID,
traceId: TRACE_ID,
tenantId: TENANT_ID,
workspaceId: WORKSPACE_ID,
userId: USER_ID,
visibility: 'private',
});

const persistence = SessionPersistenceService.getInstance();
persistence.saveSession({
id: SESSION_ID,
traceId: TRACE_ID,
traceName: 'restart.trace',
question: '分析 restart 后是否可恢复',
createdAt: GENERATED_AT,
updatedAt: GENERATED_AT + 1000,
messages: [],
metadata: {
tenantId: TENANT_ID,
workspaceId: WORKSPACE_ID,
userId: USER_ID,
},
});

const context = new EnhancedSessionContext(SESSION_ID, TRACE_ID);
context.addTurn('分析 restart 后是否可恢复', {
primaryGoal: 'restart_persistence',
aspects: ['session', 'report', 'trace'],
expectedOutputType: 'diagnosis',
complexity: 'moderate',
});
context.getEntityStore().upsertFrame({
frame_id: 'frame-restart-a',
jank_type: 'App Deadline Missed',
});
persistence.saveSessionContext(SESSION_ID, context);
}

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-restart-'));
dbPath = path.join(tmpDir, 'enterprise.sqlite');
dataDir = path.join(tmpDir, 'data');
uploadDir = path.join(tmpDir, 'uploads');

process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true';
process.env[ENTERPRISE_MIGRATION_PHASE_ENV] = 'cutover';
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true';
process.env[ENTERPRISE_DB_PATH_ENV] = dbPath;
process.env[ENTERPRISE_DATA_DIR_ENV] = dataDir;
process.env.UPLOAD_DIR = uploadDir;
delete process.env.SMARTPERFETTO_API_KEY;

SessionPersistenceService.resetForTests();
reportStore.clear();
sessionContextManager.remove(SESSION_ID);
});

afterEach(async () => {
SessionPersistenceService.resetForTests();
reportStore.clear();
sessionContextManager.remove(SESSION_ID);
restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise);
restoreEnvValue(ENTERPRISE_MIGRATION_PHASE_ENV, originalEnv.migrationPhase);
restoreEnvValue('SMARTPERFETTO_SSO_TRUSTED_HEADERS', originalEnv.trustedHeaders);
restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalEnv.enterpriseDbPath);
restoreEnvValue(ENTERPRISE_DATA_DIR_ENV, originalEnv.enterpriseDataDir);
restoreEnvValue('UPLOAD_DIR', originalEnv.uploadDir);
restoreEnvValue('SMARTPERFETTO_API_KEY', originalEnv.apiKey);
await fs.rm(tmpDir, { recursive: true, force: true });
});

describe('enterprise restart persistence', () => {
it('recovers session, report, and trace metadata from durable storage after in-memory state is lost', async () => {
await seedRestartState();
expect(readCount('trace_assets')).toBe(1);
expect(readCount('report_artifacts')).toBe(1);
expect(readCount('sessions')).toBe(1);

reportStore.clear();
sessionContextManager.remove(SESSION_ID);
SessionPersistenceService.resetForTests();

const app = makeApp();

const tracesRes = await ssoHeaders(request(app).get('/api/traces'));
expect(tracesRes.status).toBe(200);
expect(tracesRes.body.traces).toHaveLength(1);
expect(tracesRes.body.traces[0]).toEqual(expect.objectContaining({
id: TRACE_ID,
filename: 'restart.trace',
tenantId: TENANT_ID,
workspaceId: WORKSPACE_ID,
userId: USER_ID,
status: 'ready',
}));

const reportRes = await ssoHeaders(request(app).get(`/api/reports/${REPORT_ID}`));
expect(reportRes.status).toBe(200);
expect(reportRes.text).toContain('restart report');

const sessionsRes = await ssoHeaders(
request(app).get('/api/agent/v1/sessions?includeRecoverable=true'),
);
expect(sessionsRes.status).toBe(200);
expect(sessionsRes.body.recoverableSessions).toEqual([
expect.objectContaining({
sessionId: SESSION_ID,
status: 'recoverable',
traceId: TRACE_ID,
turnCount: 1,
}),
]);

const turnsRes = await ssoHeaders(
request(app).get(`/api/agent/v1/${SESSION_ID}/turns?order=asc`),
);
expect(turnsRes.status).toBe(200);
expect(turnsRes.body).toEqual(expect.objectContaining({
success: true,
sessionId: SESSION_ID,
traceId: TRACE_ID,
source: 'persistence',
totalTurns: 1,
}));
expect(turnsRes.body.turns[0]).toEqual(expect.objectContaining({
query: '分析 restart 后是否可恢复',
}));
});
});
17 changes: 12 additions & 5 deletions backend/src/services/sessionPersistenceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,25 @@ import { FocusStore, FocusStoreSnapshot } from '../agent/context/focusStore';
import { TraceAgentState } from '../agent/state/traceAgentState';
import type { SessionStateSnapshot } from '../agentv3/sessionStateSnapshot';
import { applyEnterpriseMinimalSchema } from './enterpriseSchema';
import { resolveEnterpriseDbPath } from './enterpriseDb';

// DB path is resolved lazily (in the constructor) rather than at module load.
// Module-load resolution would capture `process.cwd()` at the time of the first
// `import`, which breaks the CLI path: the CLI's bootstrap pins cwd to the
// backend root so all services share one data dir, but that chdir happens
// *after* imports have already resolved. Lazy resolution lets both HTTP (cwd
// already == backend) and CLI (cwd set by bootstrap) land on the same path.
function resolveDbDir(): string {
return path.join(process.cwd(), 'data', 'sessions');
export function resolveSessionPersistenceDbPath(env: NodeJS.ProcessEnv = process.env): string {
return resolveEnterpriseDbPath(env);
}

export class SessionPersistenceService {
private db: Database.Database;
private static instance: SessionPersistenceService;
private static instance: SessionPersistenceService | null = null;

private constructor() {
const dbDir = resolveDbDir();
const dbPath = path.join(dbDir, 'sessions.db');
const dbPath = resolveSessionPersistenceDbPath();
const dbDir = path.dirname(dbPath);

// Ensure data directory exists
if (!fs.existsSync(dbDir)) {
Expand All @@ -64,6 +65,12 @@ export class SessionPersistenceService {
return SessionPersistenceService.instance;
}

static resetForTests(): void {
if (!SessionPersistenceService.instance) return;
SessionPersistenceService.instance.db.close();
SessionPersistenceService.instance = null;
}

private initializeSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
Expand Down
7 changes: 6 additions & 1 deletion docs/features/enterprise-multi-tenant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
- [x] 3.7 Memory / RAG / Case / Baseline 表加 scope(§14.1,先 filter 后语义召回)
- [x] 3.8 双写 → 切读 → 退役 三阶段(§17),每阶段都能回滚;准备 filesystem + DB snapshot
- [x] 3.9 SecretStore:libsodium 加密 + OS keyring 解 master key + secret rotation + 读取审计
- [ ] 3.10 集成测试:backend restart 后 session/report/trace metadata 可恢复
- [x] 3.10 集成测试:backend restart 后 session/report/trace metadata 可恢复

### 0.4 主线 C:运行时隔离(§18 + §11)
- [x] 4.1 §11.10 第一批最小改动(独立 PR)
Expand Down Expand Up @@ -1045,6 +1045,11 @@ request delete
- DB 是企业模式权威状态。
- 本地 dev 仍可低成本运行。

当前已覆盖 `backend/src/routes/__tests__/enterpriseRestartPersistence.test.ts`:
清空 in-memory report/session context 并重建 `SessionPersistenceService` 后,
`/api/traces`、`/api/reports/:id`、`/api/agent/v1/sessions` 与
`/api/agent/v1/:sessionId/turns` 均可从企业 SQLite DB 与 scoped data 文件恢复。

### 主线 C:运行时隔离

任务:
Expand Down