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/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",
Expand Down
10 changes: 10 additions & 0 deletions backend/src/agent/core/orchestratorTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions backend/src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown[], { count: number }>(
'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);
});
});
134 changes: 117 additions & 17 deletions backend/src/agentv3/claudeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SessionMapEntry> {
try {
if (fs.existsSync(SESSION_MAP_FILE)) {
Expand All @@ -114,6 +127,25 @@ function loadPersistedSessionMap(): Map<string, SessionMapEntry> {
return new Map();
}

function loadSessionMapForCurrentMode(): Map<string, SessionMapEntry> {
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.
Expand Down Expand Up @@ -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). */
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */ }
Expand Down Expand Up @@ -1833,16 +1928,24 @@ 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);
this.sessionPlans.delete(sessionId);
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. */
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions backend/src/routes/agentRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading