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
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface AnalyzeSessionRunContext {
query: string;
startedAt: number;
completedAt?: number;
status: 'pending' | 'running' | 'completed' | 'failed';
status: 'pending' | 'running' | 'completed' | 'failed' | 'quota_exceeded';
error?: string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export type AssistantSessionStatus =
| 'running'
| 'awaiting_user'
| 'completed'
| 'failed';
| 'failed'
| 'quota_exceeded';

export interface ManagedAssistantSession {
sessionId: string;
Expand Down
52 changes: 52 additions & 0 deletions backend/src/routes/__tests__/enterpriseReportRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface ReportArtifactRow {
content_hash: string;
visibility: string;
created_by: string | null;
expires_at: number | null;
}

let tmpDir: string;
Expand Down Expand Up @@ -85,6 +86,31 @@ function readAuditActions(): string[] {
}
}

function writeWorkspacePolicies(input: {
retentionPolicy?: Record<string, unknown>;
}): void {
const db = openEnterpriseDb(dbPath);
const now = Date.now();
try {
db.prepare(`
INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at)
VALUES ('tenant-a', 'tenant-a', 'active', 'enterprise', ?, ?)
`).run(now, now);
db.prepare(`
INSERT OR REPLACE INTO workspaces
(id, tenant_id, name, retention_policy, quota_policy, created_at, updated_at)
VALUES
('workspace-a', 'tenant-a', 'workspace-a', ?, NULL, ?, ?)
`).run(
input.retentionPolicy ? JSON.stringify(input.retentionPolicy) : null,
now,
now,
);
} finally {
db.close();
}
}

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-report-routes-'));
dbPath = path.join(tmpDir, 'enterprise.sqlite');
Expand Down Expand Up @@ -198,4 +224,30 @@ describe('enterprise report routes', () => {
await expect(fs.access(path.dirname(row!.local_path))).rejects.toThrow();
expect(readAuditActions()).toContain('report.deleted');
});

it('applies report retention policy and hides expired cached reports', async () => {
const app = makeApp();
const reportId = 'report-expired';
writeWorkspacePolicies({
retentionPolicy: {
reportRetentionDays: 0,
},
});

persistReport(reportId, {
html: '<html><body>expired report</body></html>',
generatedAt: Date.now() - 1,
sessionId: 'session-expired',
runId: 'run-expired',
traceId: 'trace-expired',
tenantId: 'tenant-a',
workspaceId: 'workspace-a',
userId: 'user-a',
visibility: 'private',
});

expect(readReportArtifact(reportId)?.expires_at).toBeLessThanOrEqual(Date.now());
const getRes = await ssoHeaders(request(app).get(`/api/reports/${reportId}`));
expect(getRes.status).toBe(404);
});
});
73 changes: 73 additions & 0 deletions backend/src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface TraceAssetRow {
status: string;
size_bytes: number;
metadata_json: string;
expires_at: number | null;
}

let tmpDir: string;
Expand Down Expand Up @@ -172,6 +173,33 @@ function readCount(table: 'trace_assets' | 'trace_processor_leases'): number {
}
}

function writeWorkspacePolicies(input: {
quotaPolicy?: Record<string, unknown>;
retentionPolicy?: Record<string, unknown>;
}): void {
const db = openEnterpriseDb(dbPath);
const now = Date.now();
try {
db.prepare(`
INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at)
VALUES ('tenant-a', 'tenant-a', 'active', 'enterprise', ?, ?)
`).run(now, now);
db.prepare(`
INSERT OR REPLACE INTO workspaces
(id, tenant_id, name, retention_policy, quota_policy, created_at, updated_at)
VALUES
('workspace-a', 'tenant-a', 'workspace-a', ?, ?, ?, ?)
`).run(
input.retentionPolicy ? JSON.stringify(input.retentionPolicy) : null,
input.quotaPolicy ? JSON.stringify(input.quotaPolicy) : null,
now,
now,
);
} finally {
db.close();
}
}

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-trace-routes-'));
dbPath = path.join(tmpDir, 'enterprise.sqlite');
Expand Down Expand Up @@ -301,6 +329,51 @@ describe('enterprise trace metadata routes', () => {
expect(otherWorkspaceRes.status).toBe(404);
});

it('rejects uploads that exceed workspace trace quota before metadata is committed', async () => {
const app = makeApp();
writeWorkspacePolicies({
quotaPolicy: {
maxTraceBytes: 4,
},
});

const res = await ssoHeaders(
request(app)
.post('/api/traces/upload')
.attach('file', Buffer.from('12345'), 'too-large.trace'),
);

expect(res.status).toBe(413);
expect(res.body).toEqual(expect.objectContaining({
success: false,
code: 'TRACE_SIZE_QUOTA_EXCEEDED',
status: 'quota_exceeded',
}));
expect(readCount('trace_assets')).toBe(0);
expect(fakeTraceProcessorService.initializeUploadWithId).not.toHaveBeenCalled();
});

it('applies workspace trace retention policy to uploaded trace metadata', async () => {
const app = makeApp();
writeWorkspacePolicies({
retentionPolicy: {
traceRetentionDays: 3,
},
});
const beforeUpload = Date.now();

const res = await ssoHeaders(
request(app)
.post('/api/traces/upload')
.attach('file', Buffer.from('trace-with-retention'), 'retained.trace'),
);

expect(res.status).toBe(200);
const row = readTraceAsset(res.body.trace.id);
expect(row?.expires_at).toBeGreaterThanOrEqual(beforeUpload + 3 * 24 * 60 * 60 * 1000);
expect(row?.expires_at).toBeLessThanOrEqual(Date.now() + 3 * 24 * 60 * 60 * 1000);
});

it('records observed processor RSS on the frontend lease and exposes RAM budget stats', async () => {
const app = makeApp();
const sourceTracePath = path.join(tmpDir, 'rss.trace');
Expand Down
45 changes: 39 additions & 6 deletions backend/src/routes/agentRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ import {
buildTraceProcessorLeaseModeDecision,
type TraceProcessorLeaseModeDecision,
} from '../services/traceProcessorLeaseModeDecision';
import {
evaluateAnalysisRunQuota,
type EnterpriseQuotaDecision,
} from '../services/enterpriseQuotaPolicyService';
import { estimateTraceProcessorRssBytes } from '../services/traceProcessorRamBudget';
import { TraceProcessorFactory } from '../services/workingTraceProcessor';
import { registerAgentLogsRoutes } from './agentLogsRoutes';
Expand Down Expand Up @@ -167,6 +171,26 @@ function buildRunId(sessionId: string, sequence: number): string {
return `run-${sessionId}-${sequence}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

function sendAgentQuotaDenied(
res: express.Response,
decision: EnterpriseQuotaDecision,
): express.Response {
return res.status(decision.httpStatus).json({
success: false,
code: decision.code,
status: decision.status,
error: decision.message,
details: decision.details,
});
}

function terminalRunStatusForResult(
result: AgentRuntimeAnalysisResult,
): Extract<PersistedAnalysisRunStatus, 'completed' | 'failed' | 'quota_exceeded'> {
if (result.terminationReason === 'max_budget_usd') return 'quota_exceeded';
return result.success ? 'completed' : 'failed';
}

function enterpriseLeasesEnabled(): boolean {
return resolveFeatureConfig().enterprise;
}
Expand Down Expand Up @@ -302,7 +326,7 @@ function markSessionRunStatus(
): void {
if (!session.activeRun) return;
session.activeRun.status = status;
if (status === 'completed' || status === 'failed') {
if (status === 'completed' || status === 'failed' || status === 'quota_exceeded') {
session.activeRun.completedAt = Date.now();
}
session.activeRun.error = error;
Expand Down Expand Up @@ -331,7 +355,7 @@ interface AnalysisSession {
sessionId: string;
sseClients: express.Response[];
result?: AgentRuntimeAnalysisResult;
status: 'pending' | 'running' | 'awaiting_user' | 'completed' | 'failed';
status: 'pending' | 'running' | 'awaiting_user' | 'completed' | 'failed' | 'quota_exceeded';
error?: string;
traceId: string;
tenantId?: string;
Expand Down Expand Up @@ -1108,6 +1132,12 @@ async function handleAnalyzeRequest(
console.log(`[AgentRoutes] Comparison mode: current=${traceId}, reference=${referenceTraceId}`);
}

const quotaDecision = evaluateAnalysisRunQuota(requestContext);
if (!quotaDecision.allowed) {
sendAgentQuotaDenied(res, quotaDecision);
return;
}

// Initialize tools
ensureToolsRegistered();

Expand Down Expand Up @@ -1393,7 +1423,7 @@ function handleSessionStream(req: express.Request, res: express.Response, sessio

// If analysis is already completed, send the result.
// Resumed sessions may not have session.result in memory; recover from persisted turn context.
if (session.status === 'completed') {
if (session.status === 'completed' || session.status === 'quota_exceeded') {
recoverResultForSessionIfNeeded(sessionId, session);
if (session.result) {
sendAgentDrivenResult(res, session);
Expand Down Expand Up @@ -1489,7 +1519,7 @@ router.get('/:sessionId/status', (req, res) => {
observability: buildSessionObservability(session),
};

if (session.status === 'completed') {
if (session.status === 'completed' || session.status === 'quota_exceeded') {
const recoveredResult = recoverResultForSessionIfNeeded(sessionId, session);
if (recoveredResult) {
const conclusion = normalizeNarrativeForClient(recoveredResult.conclusion);
Expand Down Expand Up @@ -2824,8 +2854,11 @@ async function runAgentDrivenAnalysis(
if (idx >= 0) session.hypotheses[idx] = h;
}
}
session.status = result.success ? 'completed' : 'failed';
markSessionRunStatus(session, result.success ? 'completed' : 'failed');
const terminalRunStatus = terminalRunStatusForResult(result);
session.status = terminalRunStatus === 'quota_exceeded'
? 'quota_exceeded'
: result.success ? 'completed' : 'failed';
markSessionRunStatus(session, terminalRunStatus);

// Record conclusion in cross-turn history
if (!session.conclusionHistory) session.conclusionHistory = [];
Expand Down
Loading