Add benchmark results and tests for LSP graph builder and staged search

- Introduced a new benchmark results file for performance comparison on 2026-02-09.
- Added a test for LspGraphBuilder to ensure it does not expand nodes at maximum depth.
- Created a test for the staged search pipeline to validate fallback behavior when stage 1 returns empty results.
This commit is contained in:
catlog22
2026-02-09 21:43:13 +08:00
parent 4344e79e68
commit 362f354f1c
25 changed files with 2613 additions and 51 deletions

View File

@@ -0,0 +1,39 @@
import { existsSync, mkdirSync, appendFileSync } from 'fs';
import path from 'path';
export type CliSessionAuditEventType =
| 'session_created'
| 'session_closed'
| 'session_send'
| 'session_execute'
| 'session_resize'
| 'session_share_created'
| 'session_idle_reaped';
export interface CliSessionAuditEvent {
type: CliSessionAuditEventType;
timestamp: string;
projectRoot: string;
sessionKey?: string;
tool?: string;
resumeKey?: string;
workingDir?: string;
ip?: string;
userAgent?: string;
details?: Record<string, unknown>;
}
function auditFilePath(projectRoot: string): string {
return path.join(projectRoot, '.workflow', 'audit', 'cli-sessions.jsonl');
}
export function appendCliSessionAudit(event: CliSessionAuditEvent): void {
try {
const filePath = auditFilePath(event.projectRoot);
const dir = path.dirname(filePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
appendFileSync(filePath, JSON.stringify(event) + '\n', { encoding: 'utf8' });
} catch {
// Best-effort: never fail API requests due to audit write errors.
}
}

View File

@@ -12,6 +12,7 @@ import {
type CliSessionResumeStrategy
} from './cli-session-command-builder.js';
import { getCliSessionPolicy } from './cli-session-policy.js';
import { appendCliSessionAudit } from './cli-session-audit.js';
export interface CliSession {
sessionKey: string;
@@ -147,10 +148,29 @@ export class CliSessionManager {
private projectRoot: string;
private emitter = new EventEmitter();
private maxBufferBytes: number;
private idleTimeoutMs: number;
private reaperTimer: NodeJS.Timeout | null = null;
constructor(projectRoot: string) {
this.projectRoot = projectRoot;
this.maxBufferBytes = getCliSessionPolicy().maxBufferBytes;
const policy = getCliSessionPolicy();
this.maxBufferBytes = policy.maxBufferBytes;
this.idleTimeoutMs = policy.idleTimeoutMs;
if (this.idleTimeoutMs > 0) {
this.reaperTimer = setInterval(() => {
const reaped = this.closeIdleSessions(this.idleTimeoutMs);
for (const sessionKey of reaped) {
appendCliSessionAudit({
type: 'session_idle_reaped',
timestamp: nowIso(),
projectRoot: this.projectRoot,
sessionKey,
});
}
}, 60_000);
this.reaperTimer.unref?.();
}
}
listSessions(): CliSession[] {
@@ -354,14 +374,14 @@ export class CliSessionManager {
return () => this.emitter.off('output', handler);
}
closeIdleSessions(idleTimeoutMs: number): number {
if (idleTimeoutMs <= 0) return 0;
closeIdleSessions(idleTimeoutMs: number): string[] {
if (idleTimeoutMs <= 0) return [];
const now = Date.now();
let closed = 0;
const closed: string[] = [];
for (const s of this.sessions.values()) {
if (now - s.lastActivityAt >= idleTimeoutMs) {
this.close(s.sessionKey);
closed += 1;
closed.push(s.sessionKey);
}
}
return closed;

View File

@@ -0,0 +1,83 @@
import { randomBytes } from 'crypto';
export type CliSessionShareMode = 'read' | 'write';
export interface CliSessionShareTokenRecord {
token: string;
sessionKey: string;
projectRoot: string;
mode: CliSessionShareMode;
expiresAt: string;
}
interface InternalTokenRecord extends CliSessionShareTokenRecord {
expiresAtMs: number;
}
function createTokenValue(): string {
// 32 bytes => 43 chars base64url (approx), safe for URLs.
return randomBytes(32).toString('base64url');
}
export class CliSessionShareManager {
private tokens = new Map<string, InternalTokenRecord>();
createToken(input: {
sessionKey: string;
projectRoot: string;
mode: CliSessionShareMode;
ttlMs?: number;
}): CliSessionShareTokenRecord {
const ttlMs = typeof input.ttlMs === 'number' ? Math.max(1_000, input.ttlMs) : 24 * 60 * 60_000;
const expiresAtMs = Date.now() + ttlMs;
const record: InternalTokenRecord = {
token: createTokenValue(),
sessionKey: input.sessionKey,
projectRoot: input.projectRoot,
mode: input.mode,
expiresAt: new Date(expiresAtMs).toISOString(),
expiresAtMs,
};
this.tokens.set(record.token, record);
return record;
}
validateToken(token: string, sessionKey: string): CliSessionShareTokenRecord | null {
const record = this.tokens.get(token);
if (!record) return null;
if (record.sessionKey !== sessionKey) return null;
if (Date.now() >= record.expiresAtMs) {
this.tokens.delete(token);
return null;
}
const { expiresAtMs: _expiresAtMs, ...publicRecord } = record;
return publicRecord;
}
revokeToken(token: string): boolean {
return this.tokens.delete(token);
}
cleanupExpired(): number {
const now = Date.now();
let removed = 0;
for (const [token, record] of this.tokens) {
if (now >= record.expiresAtMs) {
this.tokens.delete(token);
removed += 1;
}
}
return removed;
}
}
let singleton: CliSessionShareManager | null = null;
export function getCliSessionShareManager(): CliSessionShareManager {
if (!singleton) singleton = new CliSessionShareManager();
return singleton;
}
export function describeShareAuthFailure(): { error: string; status: number } {
return { error: 'Invalid or expired share token', status: 403 };
}

View File

@@ -255,7 +255,7 @@ export class NodeRunner {
};
}
const manager = getCliSessionManager(process.cwd());
const manager = getCliSessionManager(this.context.workingDir || process.cwd());
const routed = manager.execute(targetSessionKey, {
tool,
prompt: instruction,