mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
Wire FlowExecutor into orchestrator routes for actual flow execution with
pause/resume/stop lifecycle management. Add CLI session audit system with
audit-routes backend and Observability tab in IssueHub frontend. Introduce
cli-session-mux for cross-workspace session routing and QueueSendToOrchestrator
UI component. Normalize frontend API response handling for { data: ... }
wrapper format and propagate projectPath through flow hooks.
In codex-lens, add per-server opened-document cache in StandaloneLspManager
to avoid redundant didOpen notifications (using didChange for updates), and
skip warmup delay for already-warmed LSP server instances in ChainSearchEngine.
155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
/**
|
|
* Audit Routes Module
|
|
* Read-only APIs for audit/observability panels.
|
|
*
|
|
* Currently supported:
|
|
* - GET /api/audit/cli-sessions - Read CLI session (PTY) audit events (JSONL)
|
|
*/
|
|
|
|
import { existsSync } from 'fs';
|
|
import { readFile } from 'fs/promises';
|
|
import { join } from 'path';
|
|
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
|
|
import type { CliSessionAuditEvent, CliSessionAuditEventType } from '../services/cli-session-audit.js';
|
|
import type { RouteContext } from './types.js';
|
|
|
|
function clampInt(value: number, min: number, max: number): number {
|
|
if (!Number.isFinite(value)) return min;
|
|
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
}
|
|
|
|
function parseCsvParam(value: string | null): string[] {
|
|
if (!value) return [];
|
|
return value
|
|
.split(',')
|
|
.map((v) => v.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function isCliSessionAuditEventType(value: string): value is CliSessionAuditEventType {
|
|
return [
|
|
'session_created',
|
|
'session_closed',
|
|
'session_send',
|
|
'session_execute',
|
|
'session_resize',
|
|
'session_share_created',
|
|
'session_share_revoked',
|
|
'session_idle_reaped',
|
|
].includes(value);
|
|
}
|
|
|
|
function matchesSearch(event: CliSessionAuditEvent, qLower: string): boolean {
|
|
if (!qLower) return true;
|
|
|
|
const haystacks: string[] = [];
|
|
if (event.type) haystacks.push(event.type);
|
|
if (event.timestamp) haystacks.push(event.timestamp);
|
|
if (event.sessionKey) haystacks.push(event.sessionKey);
|
|
if (event.tool) haystacks.push(event.tool);
|
|
if (event.resumeKey) haystacks.push(event.resumeKey);
|
|
if (event.workingDir) haystacks.push(event.workingDir);
|
|
if (event.ip) haystacks.push(event.ip);
|
|
if (event.userAgent) haystacks.push(event.userAgent);
|
|
if (event.details) {
|
|
try {
|
|
haystacks.push(JSON.stringify(event.details));
|
|
} catch {
|
|
// Ignore non-serializable details
|
|
}
|
|
}
|
|
|
|
return haystacks.some((h) => h.toLowerCase().includes(qLower));
|
|
}
|
|
|
|
/**
|
|
* Handle audit routes
|
|
* @returns true if route was handled, false otherwise
|
|
*/
|
|
export async function handleAuditRoutes(ctx: RouteContext): Promise<boolean> {
|
|
const { pathname, url, req, res, initialPath } = ctx;
|
|
|
|
// GET /api/audit/cli-sessions
|
|
if (pathname === '/api/audit/cli-sessions' && req.method === 'GET') {
|
|
const projectPathParam = url.searchParams.get('path') || initialPath;
|
|
|
|
const limit = clampInt(parseInt(url.searchParams.get('limit') || '200', 10), 1, 1000);
|
|
const offset = clampInt(parseInt(url.searchParams.get('offset') || '0', 10), 0, Number.MAX_SAFE_INTEGER);
|
|
|
|
const sessionKey = url.searchParams.get('sessionKey');
|
|
const qLower = (url.searchParams.get('q') || '').trim().toLowerCase();
|
|
|
|
const typeFilters = parseCsvParam(url.searchParams.get('type'))
|
|
.filter(isCliSessionAuditEventType);
|
|
const typeFilterSet = typeFilters.length > 0 ? new Set<CliSessionAuditEventType>(typeFilters) : null;
|
|
|
|
try {
|
|
const projectRoot = await validateAllowedPath(projectPathParam, {
|
|
mustExist: true,
|
|
allowedDirectories: [initialPath],
|
|
});
|
|
|
|
const filePath = join(projectRoot, '.workflow', 'audit', 'cli-sessions.jsonl');
|
|
if (!existsSync(filePath)) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
data: { events: [], total: 0, limit, offset, hasMore: false },
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
const raw = await readFile(filePath, 'utf-8');
|
|
const parsed: CliSessionAuditEvent[] = [];
|
|
for (const line of raw.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
parsed.push(JSON.parse(trimmed) as CliSessionAuditEvent);
|
|
} catch {
|
|
// Skip invalid JSONL line
|
|
}
|
|
}
|
|
|
|
const filtered = parsed.filter((ev) => {
|
|
if (sessionKey && ev.sessionKey !== sessionKey) return false;
|
|
if (typeFilterSet && !typeFilterSet.has(ev.type)) return false;
|
|
if (qLower && !matchesSearch(ev, qLower)) return false;
|
|
return true;
|
|
});
|
|
|
|
// Best-effort: file is append-only, so reverse for newest-first.
|
|
filtered.reverse();
|
|
|
|
const total = filtered.length;
|
|
const page = filtered.slice(offset, offset + limit);
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
data: {
|
|
events: page,
|
|
total,
|
|
limit,
|
|
offset,
|
|
hasMore: offset + limit < total,
|
|
},
|
|
}));
|
|
return true;
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const lowered = message.toLowerCase();
|
|
const status = lowered.includes('access denied') ? 403 : 400;
|
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
res.end(JSON.stringify({
|
|
success: false,
|
|
error: status === 403 ? 'Access denied' : 'Invalid request',
|
|
}));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|