mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 09:02:08 +08:00
The frontend previously juggled placeholder IDs, provider-native IDs, and session_created handoffs, which caused race conditions and provider-specific branching. This introduces app-allocated session IDs, a chat run registry with event replay, delta sidebar updates, and one kind-based websocket contract so the UI can treat every provider the same while JSONL remains the source of truth.
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { promises as fsPromises } from 'node:fs';
|
|
|
|
import chokidar, { type FSWatcher } from 'chokidar';
|
|
|
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
|
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
|
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
|
import type { LLMProvider } from '@/shared/types.js';
|
|
import { generateDisplayName } from '@/modules/projects/index.js';
|
|
|
|
type WatcherEventType = 'add' | 'change';
|
|
|
|
const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [
|
|
{
|
|
provider: 'claude',
|
|
rootPath: path.join(os.homedir(), '.claude', 'projects'),
|
|
},
|
|
{
|
|
provider: 'cursor',
|
|
rootPath: path.join(os.homedir(), '.cursor', 'projects'),
|
|
},
|
|
{
|
|
provider: 'codex',
|
|
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
|
},
|
|
// {
|
|
// provider: 'gemini',
|
|
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
|
// },
|
|
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
|
|
// which causes duplicate synchronization events.
|
|
{
|
|
provider: 'gemini',
|
|
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
|
},
|
|
{
|
|
provider: 'opencode',
|
|
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
|
},
|
|
];
|
|
|
|
const WATCHER_IGNORED_PATTERNS = [
|
|
'**/node_modules/**',
|
|
'**/.git/**',
|
|
'**/dist/**',
|
|
'**/build/**',
|
|
'**/*.tmp',
|
|
'**/*.swp',
|
|
'**/.DS_Store',
|
|
];
|
|
|
|
const PROJECTS_UPDATE_DEBOUNCE_MS = 500;
|
|
const PROJECTS_UPDATE_MAX_WAIT_MS = 2_000;
|
|
|
|
const watchers: FSWatcher[] = [];
|
|
|
|
type PendingWatcherUpdate = {
|
|
providers: Set<LLMProvider>;
|
|
changeTypes: Set<WatcherEventType>;
|
|
/**
|
|
* Provider-native session ids reported by the synchronizers. They are
|
|
* translated back to app-facing session rows at flush time, because the
|
|
* transcript file names on disk only ever contain provider ids.
|
|
*/
|
|
updatedSessionIds: Set<string>;
|
|
};
|
|
|
|
let pendingWatcherUpdate: PendingWatcherUpdate | null = null;
|
|
let pendingWatcherUpdateStartedAt: number | null = null;
|
|
let pendingWatcherFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let watcherRefreshInFlight = false;
|
|
let watcherRescheduleAfterRefresh = false;
|
|
|
|
/**
|
|
* Filters watcher events to provider-specific session artifact file types.
|
|
*/
|
|
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
|
if (provider === 'opencode') {
|
|
return path.basename(filePath) === 'opencode.db';
|
|
}
|
|
|
|
if (provider === 'gemini') {
|
|
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
|
}
|
|
|
|
return filePath.endsWith('.jsonl');
|
|
}
|
|
|
|
function clearPendingWatcherFlushTimer(): void {
|
|
if (pendingWatcherFlushTimer) {
|
|
clearTimeout(pendingWatcherFlushTimer);
|
|
pendingWatcherFlushTimer = null;
|
|
}
|
|
}
|
|
|
|
function schedulePendingWatcherFlush(): void {
|
|
if (!pendingWatcherUpdate) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (pendingWatcherUpdateStartedAt === null) {
|
|
pendingWatcherUpdateStartedAt = now;
|
|
}
|
|
|
|
const elapsed = now - pendingWatcherUpdateStartedAt;
|
|
const remainingMaxWait = Math.max(0, PROJECTS_UPDATE_MAX_WAIT_MS - elapsed);
|
|
const delay = Math.min(PROJECTS_UPDATE_DEBOUNCE_MS, remainingMaxWait);
|
|
|
|
clearPendingWatcherFlushTimer();
|
|
pendingWatcherFlushTimer = setTimeout(() => {
|
|
void flushPendingWatcherUpdate();
|
|
}, delay);
|
|
}
|
|
|
|
function queuePendingWatcherUpdate(
|
|
eventType: WatcherEventType,
|
|
provider: LLMProvider,
|
|
updatedSessionId: string | null
|
|
): void {
|
|
if (!pendingWatcherUpdate) {
|
|
pendingWatcherUpdate = {
|
|
providers: new Set<LLMProvider>(),
|
|
changeTypes: new Set<WatcherEventType>(),
|
|
updatedSessionIds: new Set<string>(),
|
|
};
|
|
}
|
|
|
|
pendingWatcherUpdate.providers.add(provider);
|
|
pendingWatcherUpdate.changeTypes.add(eventType);
|
|
if (updatedSessionId) {
|
|
pendingWatcherUpdate.updatedSessionIds.add(updatedSessionId);
|
|
}
|
|
|
|
schedulePendingWatcherFlush();
|
|
}
|
|
|
|
/**
|
|
* Builds one `session_upserted` delta event for a provider-native session id.
|
|
*
|
|
* The event carries everything a sidebar needs to upsert the session in place
|
|
* (session summary plus owning-project metadata), so clients never need a full
|
|
* project-list refetch when a transcript file changes on disk. Returns `null`
|
|
* when the id cannot be resolved to an indexed session row.
|
|
*/
|
|
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
|
|
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
|
|
?? sessionsDb.getSessionById(updatedProviderSessionId);
|
|
if (!row || row.isArchived) {
|
|
return null;
|
|
}
|
|
|
|
const projectPath = row.project_path;
|
|
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
|
|
const displayName = project?.custom_project_name?.trim()
|
|
? project.custom_project_name
|
|
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
|
|
|
|
return JSON.stringify({
|
|
kind: 'session_upserted',
|
|
sessionId: row.session_id,
|
|
provider: row.provider,
|
|
session: {
|
|
id: row.session_id,
|
|
summary: row.custom_name || '',
|
|
messageCount: 0,
|
|
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
|
},
|
|
project: project
|
|
? {
|
|
projectId: project.project_id,
|
|
path: project.project_path,
|
|
fullPath: project.project_path,
|
|
displayName,
|
|
isStarred: Boolean(project.isStarred),
|
|
}
|
|
: null,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
async function flushPendingWatcherUpdate(): Promise<void> {
|
|
clearPendingWatcherFlushTimer();
|
|
|
|
if (!pendingWatcherUpdate) {
|
|
return;
|
|
}
|
|
|
|
if (watcherRefreshInFlight) {
|
|
watcherRescheduleAfterRefresh = true;
|
|
return;
|
|
}
|
|
|
|
const queuedUpdate = pendingWatcherUpdate;
|
|
pendingWatcherUpdate = null;
|
|
pendingWatcherUpdateStartedAt = null;
|
|
watcherRefreshInFlight = true;
|
|
|
|
try {
|
|
// Per-session deltas instead of full project snapshots: an upsert of one
|
|
// session can never clobber unrelated client state, so the frontend needs
|
|
// no "suppress updates while a run is active" protection logic.
|
|
const events: string[] = [];
|
|
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
|
|
const event = await buildSessionUpsertedEvent(updatedSessionId);
|
|
if (event) {
|
|
events.push(event);
|
|
}
|
|
}
|
|
|
|
if (events.length > 0) {
|
|
connectedClients.forEach(client => {
|
|
if (client.readyState === WS_OPEN_STATE) {
|
|
for (const event of events) {
|
|
client.send(event);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message });
|
|
} finally {
|
|
watcherRefreshInFlight = false;
|
|
|
|
if (pendingWatcherUpdate || watcherRescheduleAfterRefresh) {
|
|
watcherRescheduleAfterRefresh = false;
|
|
schedulePendingWatcherFlush();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles file watcher updates and triggers provider file-level synchronization.
|
|
*/
|
|
async function onUpdate(
|
|
eventType: WatcherEventType,
|
|
filePath: string,
|
|
provider: LLMProvider
|
|
): Promise<void> {
|
|
if (!isWatcherTargetFile(provider, filePath)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await sessionSynchronizerService.synchronizeProviderFile(provider, filePath);
|
|
if (!result.indexed) {
|
|
return;
|
|
}
|
|
|
|
console.log(`Session synchronization triggered by ${eventType} event for provider "${provider}"`, {
|
|
filePath,
|
|
sessionId: result.sessionId,
|
|
});
|
|
queuePendingWatcherUpdate(eventType, provider, result.sessionId);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Session watcher sync failed for provider "${provider}"`, {
|
|
eventType,
|
|
filePath,
|
|
error: message,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts provider filesystem watchers and performs initial DB synchronization.
|
|
*/
|
|
export async function initializeSessionsWatcher(): Promise<void> {
|
|
console.log('Setting up session watchers');
|
|
|
|
const initialSync = await sessionSynchronizerService.synchronizeSessions();
|
|
console.log('Initial session synchronization complete', {
|
|
processedByProvider: initialSync.processedByProvider,
|
|
failures: initialSync.failures,
|
|
});
|
|
|
|
for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
|
|
try {
|
|
await fsPromises.mkdir(rootPath, { recursive: true });
|
|
|
|
const watcher = chokidar.watch(rootPath, {
|
|
ignored: WATCHER_IGNORED_PATTERNS,
|
|
persistent: true,
|
|
ignoreInitial: true,
|
|
followSymlinks: false,
|
|
depth: 6,
|
|
usePolling: true,
|
|
interval: 6_000,
|
|
binaryInterval: 6_000,
|
|
});
|
|
|
|
watcher
|
|
.on('add', (filePath: string) => {
|
|
void onUpdate('add', filePath, provider);
|
|
})
|
|
.on('change', (filePath: string) => {
|
|
void onUpdate('change', filePath, provider);
|
|
})
|
|
.on('error', (error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Session watcher error for provider "${provider}"`, { error: message });
|
|
});
|
|
|
|
watchers.push(watcher);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Failed to initialize session watcher for provider "${provider}"`, {
|
|
rootPath,
|
|
error: message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stops all active provider session watchers.
|
|
*/
|
|
export async function closeSessionsWatcher(): Promise<void> {
|
|
clearPendingWatcherFlushTimer();
|
|
|
|
await Promise.all(
|
|
watchers.map(async (watcher) => {
|
|
try {
|
|
await watcher.close();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error('Failed to close session watcher', { error: message });
|
|
}
|
|
})
|
|
);
|
|
watchers.length = 0;
|
|
pendingWatcherUpdate = null;
|
|
pendingWatcherUpdateStartedAt = null;
|
|
watcherRefreshInFlight = false;
|
|
watcherRescheduleAfterRefresh = false;
|
|
}
|