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 @@ -31,7 +31,7 @@ describe('verifyEnterpriseMultiTenantWindows script', () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});

test('covers current D1/D2 isolation invariants without invoking a real provider', async () => {
test('covers current D1/D2/D5 isolation invariants without invoking a real provider', async () => {
const tracePath = path.join(tempRoot, 'fixture.pftrace');
const uploadRoot = path.join(tempRoot, 'uploads');
const outputPath = path.join(tempRoot, 'report.json');
Expand All @@ -45,10 +45,11 @@ describe('verifyEnterpriseMultiTenantWindows script', () => {
});

expect(report.passed).toBe(true);
expect(report.checks).toEqual({ D1: true, D2: true });
expect(report.checks).toEqual({ D1: true, D2: true, D5: true });
expect(Object.values(report.scenarios.D1.checks)).toEqual(expect.arrayContaining([true]));
expect(Object.values(report.scenarios.D1.checks).every(Boolean)).toBe(true);
expect(Object.values(report.scenarios.D2.checks).every(Boolean)).toBe(true);
expect(Object.values(report.scenarios.D5.checks).every(Boolean)).toBe(true);
expect(report.coverageLimitations.join('\n')).toContain('TraceProcessorLease');
expect(process.env.UPLOAD_DIR).toBe(previousUploadDir);

Expand All @@ -58,6 +59,6 @@ describe('verifyEnterpriseMultiTenantWindows script', () => {

const traceFiles = (await fs.readdir(path.join(uploadRoot, 'traces')))
.filter(file => file.endsWith('.trace'));
expect(traceFiles).toHaveLength(6);
expect(traceFiles).toHaveLength(7);
});
});
107 changes: 105 additions & 2 deletions backend/src/scripts/verifyEnterpriseMultiTenantWindows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { applyEnterpriseMinimalSchema } from '../services/enterpriseSchema';
import type { EnterpriseRepositoryScope } from '../services/enterpriseRepository';
import {
getTracesDir,
listTraceMetadata,
readTraceMetadataForContext,
writeTraceMetadata,
} from '../services/traceMetadataStore';
import { TraceProcessorLeaseStore } from '../services/traceProcessorLeaseStore';
import { ownerFieldsFromContext } from '../services/resourceOwnership';
import type { RequestContext } from '../middleware/auth';

type ScenarioName = 'D1' | 'D2';
type ScenarioName = 'D1' | 'D2' | 'D5';

interface VerifyOptions {
tracePath?: string;
Expand Down Expand Up @@ -170,6 +172,14 @@ function context(label: string, input: {
};
}

function leaseScope(wc: WindowContext): EnterpriseRepositoryScope {
return {
tenantId: wc.context.tenantId,
workspaceId: wc.context.workspaceId,
userId: wc.context.userId,
};
}

function seedIdentity(db: Database.Database, wc: WindowContext): void {
const now = Date.now();
const { tenantId, workspaceId, userId } = wc.context;
Expand Down Expand Up @@ -502,6 +512,96 @@ async function scenarioD2(
};
}

async function scenarioD5(
db: Database.Database,
tracePath: string,
userAWindow1: WindowContext,
): Promise<ScenarioReport> {
const upload = await simulateUpload(db, userAWindow1, tracePath, 'd5-user-a-sleep.pftrace');
const store = new TraceProcessorLeaseStore(db);
const scope = leaseScope(userAWindow1);
const holderRef = userAWindow1.context.windowId ?? userAWindow1.label;
const startedAt = 1_777_000_000_000;
const offlineAt = startedAt + 30_000;
const insideGraceAt = offlineAt + 30 * 60 * 1000 - 1;
const afterGraceAt = offlineAt + 30 * 60 * 1000 + 1;
const recoveredAt = afterGraceAt + 1_000;

let lease = store.acquireHolder(scope, upload.traceId, {
holderType: 'frontend_http_rpc',
holderRef,
windowId: holderRef,
frontendVisibility: 'visible',
metadata: { scenario: 'D5' },
}, { now: startedAt });
store.markStarting(scope, lease.id);
lease = store.markReady(scope, lease.id);

lease = store.acquireHolderForLease(scope, lease.id, {
holderType: 'frontend_http_rpc',
holderRef,
windowId: holderRef,
frontendVisibility: 'offline',
metadata: {
heartbeat: 'frontend',
scenario: 'D5',
},
}, { now: offlineAt });
const offlineHolder = lease.holders.find(holder => holder.holderRef === holderRef);

const insideGraceSweep = store.sweepExpired(insideGraceAt);
const insideGraceLease = store.getLeaseById(scope, lease.id);

const afterGraceSweep = store.sweepExpired(afterGraceAt);
const afterGraceLease = store.getLeaseById(scope, lease.id);

const recoveredLease = store.acquireHolderForLease(scope, lease.id, {
holderType: 'frontend_http_rpc',
holderRef,
windowId: holderRef,
frontendVisibility: 'visible',
metadata: {
heartbeat: 'frontend',
scenario: 'D5',
recovery: 'pageshow',
},
}, { now: recoveredAt });
const recoveredHolder = recoveredLease.holders.find(holder => holder.holderRef === holderRef);

const checks = {
offlineHeartbeatKeepsThirtyMinuteGrace: (offlineHolder?.expiresAt ?? 0) >= insideGraceAt,
leaseStaysActiveInsideOfflineGrace: insideGraceSweep.holdersRemoved === 0
&& insideGraceLease?.state === 'active'
&& insideGraceLease.holderCount === 1,
staleOfflineHolderDoesNotReleaseLease: afterGraceSweep.holdersRemoved === 1
&& afterGraceLease?.state === 'idle'
&& afterGraceLease.holderCount === 0,
pageshowHeartbeatReacquiresSameLease: recoveredLease.id === lease.id
&& recoveredLease.state === 'active'
&& recoveredLease.holderCount === 1
&& recoveredHolder?.metadata?.frontendVisibility === 'visible'
&& recoveredHolder.expiresAt !== null
&& recoveredHolder.expiresAt >= recoveredAt + 90_000,
};

return {
checks,
details: {
traceId: upload.traceId,
leaseId: lease.id,
offlineHolderExpiresAt: offlineHolder?.expiresAt ?? null,
insideGraceAt,
afterGraceAt,
recoveredAt,
insideGraceSweep,
afterGraceSweep,
afterGraceLeaseState: afterGraceLease?.state ?? null,
recoveredLeaseState: recoveredLease.state,
recoveredHolder,
},
};
}

function allChecksPassed(report: EnterpriseWindowRegressionReport): boolean {
return Object.values(report.scenarios).every(scenario =>
Object.values(scenario.checks).every(Boolean),
Expand Down Expand Up @@ -563,21 +663,24 @@ export async function runEnterpriseWindowRegression(
windows.userBWindow1,
input.longSqlMs ?? 100,
),
D5: await scenarioD5(db, tracePath, windows.userAWindow1),
};
const report: EnterpriseWindowRegressionReport = {
timestamp: new Date().toISOString(),
passed: false,
checks: {
D1: scenarioPassed(scenarios.D1),
D2: scenarioPassed(scenarios.D2),
D5: scenarioPassed(scenarios.D5),
},
uploadRoot,
tracePath,
scenarios,
coverageLimitations: [
'D1 covers same-name trace isolation across three users and two windows at the trace metadata, TraceAsset, workspace RBAC, and analysis session/run schema layers.',
'D2 covers a deterministic long-SQL window at the run/event metadata layer without invoking a real LLM provider.',
'Production TraceProcessorLease holder/state assertions are covered by the §0.4.4 lease store and route tests; backend proxy and queue behavior remain future §0.7 D1/D2 final-acceptance work.',
'D5 covers TraceProcessorLease holder grace and pageshow-style reacquire semantics after offline heartbeat expiry; frontend stale-lease reload signaling is covered by HttpRpcEngine unit tests.',
'Production backend proxy and queue behavior remain future §0.7 D1/D2/D5 final-acceptance work against a live browser and trace_processor_shell.',
],
};
report.passed = allChecksPassed(report);
Expand Down
2 changes: 1 addition & 1 deletion docs/features/enterprise-multi-tenant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
- [ ] D2 A 长 SQL 中,B 上传并分析另一个 trace → A 的 SSE 不断、A lease 不被 destroy、B 能排队或运行
- [ ] D3 A 前端 timeline,B 跑 full agent → A WebSocket 走 lease;P0 不被 P2 长任务无限阻塞
- [ ] D4 trace_processor_shell crash → leaseId 稳定;前端不持有旧 port;supervisor 单点重启
- [ ] D5 浏览器断网 / 休眠 30 分钟后恢复 → frontend grace 生效;reacquire lease 或自动 reload
- [x] D5 浏览器断网 / 休眠 30 分钟后恢复 → frontend grace 生效;reacquire lease 或自动 reload
- [ ] D6 SSE 在 conclusion 后、analysis_completed 前断开 → AgentEvent replay 能补回 reportUrl
- [ ] D7 手动 cleanup / delete → running run / active lease / 正在生成的 report 被 draining 保护
- [ ] D8 Provider 配置在 session 中途变更 → resume 校验 ProviderSnapshot hash,不复用错误 SDK session
Expand Down
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<script defer src="/assistant-flamegraph.js"></script>
<script defer src="/assistant-critical-path.js"></script>
</head>
<body data-perfetto_version='{"stable":"v54.0-5fc6a21fb"}'>
<body data-perfetto_version='{"stable":"v54.0-a42c0bb58"}'>
<!--
Don't add any content here. The whole <body> is replaced by
frontend/index.ts when bootstrapping. This is only used for very early
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,12 @@
"assets/rec_wifi.png": "sha256-/qAHbRlch8q/mJM7JEjHE4vD2WtjKxTzYkpJ7NVkATY=",
"assets/scheduling_latency.png": "sha256-o08K+ewOuGio7sBa83hTtd1/rJZU7M/nYvF927TLyWI=",
"engine_bundle.js": "sha256-hUB4MkOoKPgLkP92tceioIKihhm7VQ+Ja31iml06qL8=",
"frontend_bundle.js": "sha256-h0Ucl1C8Ue4dDTAFINxuDwTKc26mOUtra5zx4Fo6VH0=",
"frontend_bundle.js": "sha256-YcdOmj61UQiqY8j+Roczu7iLkdkwAqk4TRrYgKMB5JE=",
"perfetto.css": "sha256-WYuAWLHVl5m695UPT/MBUV4IwGdWQ1hJxZcM+/jh0/U=",
"proto_utils.wasm": "sha256-h0hc590gMZcp+GLrTS4GxxV9h1PpWO8KbJKHvyzGEKQ=",
"stdlib_docs.json": "sha256-1kil8DNh6+njcYl1lMVjTAefwgVJNmhSWbidm8cMGKg=",
"trace_processor_memory64.wasm": "sha256-r+6POysVFf+DmWPmzjgp77lYlMJRMUrh+xSUOxMTlx0=",
"traceconv.wasm": "sha256-fOGvchjuaAAAeUWgbuKnqjdLGSXcaIF1RSPhoEeOHos=",
"traceconv_bundle.js": "sha256-WvHxIoIHXjqh+1DGZGeYHvCLfVE24Uhicd5bvkA9OOY="
"traceconv_bundle.js": "sha256-KD8IjDXsBB4ydjORTCd4Ez/6y2ZinflcHkYb8PSqxh4="
}
}